Generate concise human readable time ranges in Laravel
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 Time | End Time | Time Range |
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 |
13:30:00 | 13:30:00 | 1: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 columnNotifications 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 usingCarbon::parse()
, and quietly fall back tonull
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!