# 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.

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

## 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 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
<?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:

```php
// 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](https://pestphp.com/), but is easily adaptable for classic PHPUnit.

```php
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1675293393030/08a4409c-cf02-45a6-8f1e-853bacd66b75.png align="center")

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!
