Generate concise human readable time ranges in Laravel

·

4 min read

In this article, I share a simple helper to improve the developer experience when working with time ranges in Laravel. You will be able to drop it into your applications as-is or modify it as needed.

Note: At the time of publishing, the code in this article was written for Laravel v9.50.2 and PHP v8.1.13.

Problem

We have a Delivery model with two fields start_time and end_time (MySQL time columns). How can we translate the raw values into a more human-readable time range (e.g., 8:15am - 12pm), in a way that is more intelligent than naively concatenating the two values?

Or in general: how do we translate a pair of raw time values into a human-readable time range?

Solution

The high-level algorithm:

  • Normalize times to 12-hour format without leading zeroes

  • Not concerned about seconds, only hours and minutes

  • Attach am or pm as needed, non-redundantly

  • Strip minutes if not needed (e.g., 1:00pm simplifies to 1pm)

  • Collapse to a single time (non-range) if start and end are equal

To illustrate further, here are more time ranges with their expected outputs:

Start TimeEnd TimeTime Range
10:00:0012:00:0010am - 12pm
10:00:0011:00:0010 - 11am
08:15:0012:00:008:15am - 12pm
13:30:0018:15:001:30 - 6:15pm
01:00:0013:00:001am - 1pm
01:25:0013:00:001:25am - 1pm
13:30:0013:30:001:30pm

This comes in handy wherever we need to express time ranges in a more natural human-readable format, such as:

  • On a Delivery index page, showing delivery time windows within a single column

  • Notifications and mail messages where short concise messaging is essential

  • Within a calendar grid or other frontend UI component where space is limited

Implementation

For this article, I've chosen to implement a TimeRange helper class with a static method stringify that accepts three parameters:

  • $start - Start Time

  • $end - End Time

  • $glue - Optional time range delimiter (defaults to ' - ')

The optional glue parameter will allow, for example, expressing time ranges as "10 to 11am". Here's the code:

<?php

namespace App\Support;

use Illuminate\Support\Carbon;

class TimeRange
{
    public static function stringify($start, $end, $glue = ' - ')
    {
        // Attempt to parse the start and end
        $start = $start ? rescue(fn () => Carbon::parse($start)) : null;
        $end = $end ? rescue(fn () => Carbon::parse($end)) : null;

        $start24h = $start?->format('H:i');
        $end24h = $end?->format('H:i');

        // If times are identical, return early
        if ($start24h === $end24h) {
            return $start?->format('g:ia');
        }

        // Stringify the start and end
        $startStr = str($start?->format('g:i'));
        $endStr = str($end?->format('g:i'));

        // Extract the periods (am/pm)
        $startPeriod = $start?->format('a');
        $endPeriod = $end?->format('a');

        // Strip redundant :00 from the end of the strings
        $startStr = $startStr->whenEndsWith(':00', fn ($s) => $s->beforeLast(':00'));
        $endStr = $endStr->whenEndsWith(':00', fn ($s) => $s->beforeLast(':00'));

        $suffix = '';

        // Only attach individual periods if they differ
        if ($startPeriod !== $endPeriod) {
            $startStr = $startStr->append($startPeriod);
            $endStr = $endStr->append($endPeriod);
        } else {
            $suffix = $endPeriod;
        }

        $combined = collect([$startStr->toString(), $endStr->toString()])
            ->reject(fn ($s) => blank($s))
            ->join($glue);

        return $combined.$suffix;
    }
}

I'm satisfied with the implementation for our needs, but should note few things:

  • It will attempt to parse the specified $start and $end times using Carbon::parse(), and quietly fall back to null if an unparseable value is given.

  • It will not validate whether the pair of start and end times are in the correct intended order. e.g., TimeRange::stringify('13:00', '10:00') would yield a time range that doesn't make sense ("1pm - 10am")

  • At the time of publishing, this was written for Laravel v9.50.2 and PHP v8.1.13.

Usage

Here are a few examples of hypothetical usage:

// With a model
TimeRange::stringify($delivery->start_time, $delivery->end_time);

// With Carbon values
TimeRange::stringify(now(), now()->addHours(3));

// With unix timestamps
TimeRange::stringify(time(), strtotime('+3 hours'));

// With PHP DateTime objects
TimeRange::stringify(new DateTime(), new DateTime('+3 hours'));

// With custom glue
TimeRange::stringify(now(), now()->addHours(3), ' to ');

Writing a Test

Here's a supporting test covering the most practical cases plus a few edge cases where invalid times are used. It is written with Pest, but is easily adaptable for classic PHPUnit.

use App\Support\TimeRange;

it('can generate time range', function ($start, $end, $result) {
    expect(TimeRange::stringify($start, $end))->toBe($result);
})->with([
    // Start Time, End Time, Expected Result
    ['10:00:00', '12:00:00', '10am - 12pm'],
    ['10:00:00', '11:00:00', '10 - 11am'],
    ['08:15:00', '12:00:00', '8:15am - 12pm'],
    ['13:30:00', '18:15:00', '1:30 - 6:15pm'],
    ['01:00:00', '13:00:00', '1am - 1pm'],
    ['01:25:00', '13:00:00', '1:25am - 1pm'],
    ['01:25:00', '13:25:00', '1:25am - 1:25pm'],
    ['13:30:00', '13:30:00', '1:30pm'],
    ['10:01', '11:10', '10:01 - 11:10am'],
    [null, '18:15:00', '6:15pm'],
    ['13:30:00', null, '1:30pm'],
    ['invalid-time', '13:30:00', '1:30pm'],
    ['13:30:00', 'invalid-time', '1:30pm'],
    ['invalid-time', 'invalid-time', null],
    ['9', '12', null],
]);

And the corresponding test results:

You're free to modify as needed, this is just a starting point. If you're not yet in the habit of writing tests for your applications, I hope this will nudge you in that direction to start.

Conclusion

If you reached this far, congratulations. If you found this snippet useful or have used it in your applications, I'd love to hear about it. Cheers!