Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Hours Helper Laravel Package

label84/laravel-hours-helper

Generate an Illuminate\Support\Collection of dates and times at a set interval for a given period—ideal for calendar dropdowns, meeting invites, and event scheduling. Supports custom formatting, excluded intervals, past-midnight ranges, and multi-day spans.

View on GitHub
Deep Wiki
Context7

Getting Started

Install — one command, zero config:

composer require label84/laravel-hours-helper

The service provider and HoursHelper facade are auto-discovered. There is no config file to publish, no migration, no env var. The ^1.4 line supports Laravel 11/12/13; older versions will fail at Composer resolution rather than at runtime, which is the good kind of failure.

First call:

use Label84\HoursHelper\Facades\HoursHelper;

$slots = HoursHelper::create('08:00', '18:00', 30);

// Collection: '08:00', '08:30', '09:00', ... '18:00'

The signature you'll use everywhere:

HoursHelper::create(
    string $start,        // 'H:i' or 'Y-m-d H:i'
    string $end,
    int $interval,        // minutes
    string $format = 'H:i',
    array $exclude = []   // [['09:00', '09:59'], ...]
);

It returns an Illuminate\Support\Collection of strings. That's the entire value proposition: it replaces the for loop + Carbon::parse + ->format() + exclusion filtering that you'd otherwise hand-write for every scheduling feature in the app.

Where to look first:

  1. The four README examples cover format, exclusions, past-midnight, and multi-day — roughly 90% of real usage.
  2. vendor/label84/laravel-hours-helper/tests/HoursHelperTest.php. The README is thin on boundary and exclusion semantics; the test file is the de-facto spec. Read it before shipping anything that depends on exclusion behaviour.

First realistic use case — a slot dropdown on a booking form, generated in the controller so the same collection can be reused for validation:

public function create()
{
    $slots = HoursHelper::create('09:00', '17:00', 30, 'H:i', [
        ['12:00', '12:59'], // lunch
    ]);

    return view('bookings.create', compact('slots'));
}
@foreach ($slots as $slot)
    <option value="{{ $slot }}">{{ $slot }}</option>
@endforeach

Implementation Patterns

1. Generate once, use for both rendering and validation. The highest-leverage pattern with this package: because the output is a plain collection of strings, the thing you render is the whitelist you validate against. Put generation in one place so they can never drift.

class SlotGenerator
{
    public function forDate(CarbonInterface $date, array $busy = []): Collection
    {
        return HoursHelper::create(
            $date->copy()->setTime(9, 0)->format('Y-m-d H:i'),
            $date->copy()->setTime(17, 0)->format('Y-m-d H:i'),
            30,
            'Y-m-d H:i',
            $this->normalise($busy),
        );
    }
}
$slots = $generator->forDate($date, $busy);

$request->validate([
    'slot' => ['required', Rule::in($slots->all())],
]);

2. Derive exclusions from the database, and normalise them yourself. Fetch busy ranges, format them to match what you're generating, and pass them in. The package matches generated slots against the list independently — it does not merge overlapping ranges, sort them, or detect partial overlaps. Do that in your own code:

$busy = Booking::whereDate('starts_at', $date)
    ->orderBy('starts_at')
    ->get()
    ->map(fn ($b) => [
        $b->starts_at->format('H:i'),
        $b->ends_at->copy()->subMinute()->format('H:i'), // endpoints are inclusive
    ])
    ->values()
    ->all();

$available = HoursHelper::create('09:00', '17:00', 15, 'H:i', $busy);

Watch for the granularity problem: if a booking is 09:10–09:40 and your interval is 30 minutes, the generated 09:00 slot doesn't fall inside the exclusion, so it leaks through as "available" despite overlapping the booking. If your domain has irregular durations, either align the interval to the booking grid or filter overlaps yourself after rehydrating to Carbon.

3. Multi-day ranges: pass full datetimes and a date-preserving format.

$slots = HoursHelper::create('2026-04-01 08:00', '2026-04-03 18:00', 60, 'Y-m-d H:i');

This is the correct way to span days — don't loop the helper per day and concatenate unless you actually want per-day caching or per-day availability rules. Note that a multi-day range is one flat list with no day grouping; if your UI groups by day, ->groupBy(fn ($s) => substr($s, 0, 10)) after the fact.

4. Rehydrate to Carbon at the boundary. Treat create() as a presentation-layer generator, not a domain model. As soon as you need arithmetic, timezone conversion, or persistence, stop using strings:

use Illuminate\Support\Carbon;

$carbonSlots = HoursHelper::create('09:00', '17:00', 30)
    ->map(fn ($slot) => Carbon::parse("{$date->toDateString()} {$slot}", config('app.timezone')));

This is also the right place to filter out already-past slots for "today" and to convert into a user's timezone.

5. JSON endpoints need ->values(). Collection keys survive filter()/reject()/map(). If the keys aren't sequential, toJson() emits an object instead of an array and the frontend's .map() throws:

return response()->json([
    'slots' => $available->values(),
]);

Call ->values() after any transformation, unconditionally. It's cheap and it's the single most common bug shape with this package.

6. Cache the static grids. Business hours and standard appointment blocks rarely change. The result is a collection of scalars, so it caches cleanly:

$slots = Cache::remember("slots:{$date->toDateString()}", now()->addDay(), fn () => $generator->forDate($date));

Key by date and by whatever availability inputs feed the exclusions, or you'll serve stale slots after a booking is made. For anything booking-driven, cache the open-hours grid and apply busy ranges at request time.

7. Wrap the facade behind your own interface. This is a single-class package with a ~5-argument static API and 277 stars. That's fine day-to-day, but wrapping it costs an interface and buys you three things: a seam for unit tests without touching the facade, a home for normalisation/DST/granularity logic, and a one-file replacement path if it's ever abandoned. It's roughly an afternoon of code to inline.

8. Test with a frozen clock, and test the boundaries you don't understand. Anything combining generated slots with now() will go red at 23:59 unless you use Carbon::setTestNow() in setUp(). And write an explicit test asserting what ['09:00', '09:30'] excludes — see below.


Gotchas and Tips

The default 'H:i' format silently discards the date. If your range crosses midnight or spans days and you leave the format alone, you get ambiguous strings and possible duplicate values. Always pass an explicit format whenever the range can span days.

Both endpoints are inclusive. create('08:00', '09:30', 30) returns four entries including 09:30. A 09:00–17:00 window at 30 minutes produces a slot starting at 17:00 — most booking UIs don't want that. Trim the tail if your domain treats the end as exclusive. This also determines how you construct exclusion upper bounds: subtract a minute (->subMinute()) when deriving from a booking's ends_at, or you'll eat the following slot.

Exclusion boundary semantics are under-documented. README example 2 excludes ['09:00', '09:59'] and removes the 09:00 slot, which tells you exclusions are matched against generated intervals with an inclusive-looking upper bound. Whether ['09:00', '09:30'] removes the 09:30 slot is not documented. Write a test with your real exclusion data rather than reasoning about it.

Overlaps, nesting, and ordering of exclusions are not handled. Each generated slot is presumably tested against the list independently, so [['09:00','10:00'], ['09:30','10:30']] probably works — but unsorted, nested, or past-midnight-wrapping exclusion ranges are untested territory. Normalise and merge before passing them in.

Past-midnight ranges work, but past-midnight exclusions were a later addition (v1.2.2). If your range wraps midnight and also uses exclusions, be unusually thorough in testing, and pin to ^1.4 rather than floating.

Timezone is entirely your problem. There is no timezone parameter. The helper formats strings; it has no notion of config('app.timezone'). For multi-timezone apps, generate in the storage timezone and convert per-user after rehydrating to Carbon. Same story for DST: a fixed-minute interval across a spring-forward boundary will emit wall-clock times that don't exist. If DST matters, generate per-day and skip/adjust the missing hour yourself.

Memory scales linearly with no warning and no lazy variant. 15-minute intervals over a month is ~2,900 strings — fine. A 1-minute interval over a year is 525,600 strings, each compared against every exclusion. Chunk by day and merge, or cache. There is no streaming alternative.

Interval is whole minutes, integer-typed. No seconds precision, no fractional hours. If you need "every 90 seconds," this is the wrong tool — don't fight it.

Zero input validation. start > end (without past-midnight intent), interval <= 0, interval larger than the range, malformed time strings — all yours. An interval of 0 is a plausible infinite loop. Guard at your service boundary:

abort_if($interval < 1, 400);

Filtered collections serialize as JSON objects. Repeating this because it's the most common failure: {"1": "10:00", "2": "10:30"} breaks frontend .map(). ->values() after every transformation, always.

Mocking in tests. Because it's a facade, HoursHelper::shouldReceive('create')->andReturn(collect([...])) works, but you're better off mocking your SlotGenerator interface and letting the package run for real in a small number of integration tests. Facade mocks hide the boundary-inclusivity behaviour that actually causes bugs.

Extension points are effectively nil. No config, no bindings to override, no custom formatter hook. If you need behaviour beyond the five parameters — per-day business hours, half-open intervals, timezone awareness, partial-overlap detection — you write it around the package, not inside it. Keep that in mind when deciding how far a single SlotGenerator wrapper should grow.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core