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

Filament Business Hours Laravel Package

laboiteacode/filament-business-hours

Filament field for managing business opening hours on a drag-and-drop weekly 24-hour grid, with exceptional closures, special hours, vacation periods, and timezones. Stores data natively in spatie/opening-hours format.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up in three steps:

composer require laboiteacode/filament-business-hours

php artisan vendor:publish --tag=filament-business-hours-migrations
php artisan migrate
// app/Providers/Filament/AdminPanelProvider.php
use LaBoiteACode\FilamentBusinessHours\FilamentBusinessHoursPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(FilamentBusinessHoursPlugin::make());
}

A Business hours page shows up in the panel navigation immediately. Open it, drag some ranges onto Monday–Friday, add a "Christmas" exceptional closure, hit Save.

Where to look first, in this order:

  1. config/filament-business-hours.php after php artisan vendor:publish --tag=filament-business-hours-config — this is the fastest way to see every knob (timezone list, slot step, first day of week, authorization gate) before you reach for the fluent API.
  2. LaBoiteACode\FilamentBusinessHours\Models\BusinessHours — the entire read-side contract is two methods, resolve($key) and openingHours($key).
  3. The business_hours table itself — key, timezone, overflow, hours (json), exceptions (json). Nothing magic; you can inspect it with tinker and understand the whole package in a minute.

First real use case — show "Open now / Closed" on a storefront, using the hours an admin just saved in the panel:

use LaBoiteACode\FilamentBusinessHours\Models\BusinessHours;

$hours = BusinessHours::openingHours(); // null if never saved

if ($hours?->isOpen()) {
    echo 'Open until ' . $hours->nextClose(now())->format('H:i');
} else {
    echo 'Opens ' . $hours->nextOpen(now())->format('l \a\t H:i');
}

That's the whole value proposition: the panel is just an editor for a spatie/opening-hours definition, and BusinessHours::openingHours() hands you the spatie object back. No export step, no sync job, no mapping layer.

Implementation Patterns

One record per location, one panel per record

The plugin manages one row of business_hours, identified by key. For a multi-location app, give each panel (or each plugin instance) its own record:

FilamentBusinessHoursPlugin::make()
    ->record('shop-paris')
    ->navigationLabel('Paris hours')
    ->slug('hours/paris')
    ->cluster(Settings::class)
    ->timezones(['Europe/Paris', 'Europe/Brussels'])
    ->defaultTimezone('Europe/Paris');

Then BusinessHours::openingHours('shop-paris') on the storefront side. This is the intended multi-tenancy story — there is no built-in tenant_id column, so if you need per-tenant hours, encode the tenant in the key (tenant-42-paris) and build the panel plugin dynamically in panel().

Read through the model, cache the array

OpeningHours::create() re-parses the whole definition on every call. On a header widget rendered on every request, cache the array, not the object (objects don't survive serialization cleanly across cache drivers):

use Spatie\OpeningHours\OpeningHours;
use LaBoiteACode\FilamentBusinessHours\Models\BusinessHours;

$data = cache()->rememberForever(
    'business-hours:default',
    fn () => BusinessHours::resolve('default')?->toOpeningHoursArray() ?? [],
);

$openingHours = OpeningHours::create($data);

Invalidate it with a model observer, since the panel writes through the same model:

BusinessHours::saved(fn ($record) => cache()->forget("business-hours:{$record->key}"));
BusinessHours::deleted(fn ($record) => cache()->forget("business-hours:{$record->key}"));

A short TTL (now()->addHour()) is a pragmatic alternative if you'd rather not register observers for a low-traffic admin page.

Write from seeders, imports, or your own commands

The doors swing both ways — the page picks up rows written from code on next load:

BusinessHours::updateOrCreate(['key' => 'shop-paris'], [
    'timezone'   => 'Europe/Paris',
    'overflow'   => false,
    'hours'      => [
        'monday'    => ['09:00-12:00', '14:00-18:00'],
        'tuesday'   => ['09:00-18:00'],
        'wednesday' => ['09:00-18:00'],
    ],
    'exceptions' => [
        '01-01' => ['hours' => [], 'data' => 'New Year'],
        '2026-08-03 to 2026-08-16' => ['hours' => [], 'data' => 'Summer break'],
    ],
]);

Note the exception key formats: Y-m-d for one-offs, m-d for yearly recurrence, Y-m-d to Y-m-d for ranges. Getting these wrong is silent — spatie just won't match them — so prefer writing them once and letting the UI own future edits.

Authorization: gate in config, closure for anything dynamic

->canAccessUsing(fn (): bool => auth()->user()?->can('manageBusinessHours') ?? false)

canAccessUsing() wins over the authorization.gate config value and drives both the nav item and the route, so you don't get the classic "hidden link, accessible URL" bug.

Per-panel presentation without touching config

Everything in the config file has a fluent twin that wins per panel. Use it to keep a shared install's defaults untouched while one panel gets different branding:

FilamentBusinessHoursPlugin::make()
    ->navigationGroup('Shop')
    ->navigationIcon('heroicon-o-clock')
    ->firstDayOfWeek('sunday')   // display only; storage stays spatie-shaped
    ->slotStep(30);              // grid snapping in minutes

Consuming labels in your UI

Anything typed into the label field ("Christmas", "Summer break") round-trips as spatie data:

$today = $hours->forDate(now());
$label = $today->data; // 'Christmas', or null on a normal day

That's how you render "Closed — Christmas" in a storefront banner rather than a bare "Closed".

Testing against it

Because the model is plain Eloquent, you can build state in a test without touching Livewire:

BusinessHours::create([
    'key' => 'default',
    'timezone' => 'UTC',
    'hours' => ['monday' => ['09:00-17:00']],
    'exceptions' => [],
]);

$this->assertTrue(BusinessHours::openingHours()->isOpenAt(new DateTime('2026-01-05 10:00')));

The package ships composer test, composer analyse, composer format — worth running composer analyse in your own app after swapping in a custom model.

Gotchas and Tips

Compatibility is narrower than it looks. PHP 8.2–8.5, Laravel 12–13, Filament 4–5. If you're on Laravel 11 or Filament 3, this will not install — no backports, no branch aliases mentioned. Check composer why-not before promising it to a client.

Saving is explicit, and all-or-nothing. The Save header action persists the grid, exceptional dates, vacation periods and the settings modal (timezone, overflow) in one record write, validated against spatie itself. There's no per-range autosave; a Livewire re-render mid-edit loses unsaved grid changes. Don't build a custom "quick toggle open/closed" button that assumes the page state is already persisted.

Overlapping dates are rejected, not merged. A single exceptional date that falls inside a vacation period fails validation with a readable message. Same for two overlapping vacation periods. If you're importing holiday calendars from an external source, de-duplicate and merge ranges before writing — the page will refuse to save a doubled date.

Ranges past midnight are off by default. With overflow false, the grid is strictly 00:00–24:00 per day, so a bar closing at 02:00 must be modelled as 20:00-24:00 Friday + 00:00-02:00 Saturday. Turning on "Ranges past midnight" in the settings modal (or overflow => true when seeding) switches spatie into overflow mode and accepts 20:00-02:00 — but note the grid clamps those to midnight visually, so the editor is the only place a human can verify the true end time. Pick one convention per record and document it; mixing models across locations makes downstream isOpenAt() reasoning error-prone.

timezone null does not mean UTC. It means "use the application timezone." Set it explicitly for anything user-facing across regions, and seed it for records created outside the panel — otherwise isOpenAt() silently shifts by your APP_TIMEZONE.

Ranges get merged and sorted on save. 09:00-12:00 + 12:00-14:00 becomes 09:00-14:00. Don't rely on adjacency being preserved to encode "lunch break ends / afternoon begins"; if you need distinct segments, they must have a real gap.

slotStep is a grid affordance, not a data constraint. The inline editor accepts any minute regardless of the snap step, so your stored data can and will contain 09:07-17:43. Never assume quarter-hour boundaries when querying or rendering — always go through spatie's methods rather than string-comparing times.

Hand-written exceptions survive, but invisibly. The README is explicit: entries the UI can't represent (recurring m-d to m-d periods, structured data payloads) are preserved verbatim across edits. Great for advanced seeding; the flip side is that a record's exceptions in the database may not match what an admin sees in the page. If you hand-seed exotic exceptions, tell the ops team — otherwise someone will "fix" the page, save, and assume the rest is intact.

Hard caps will bite imports. 100 ranges per day, 500 exceptional dates + vacation periods combined, 1100 days per vacation period, 255 characters per label. These are Livewire-payload defences, and they're enforced on save — so a bulk-imported calendar with hundreds of one-off closures will fail to persist through the UI even if the array itself is valid spatie. Seed large datasets via BusinessHours::create(), bypassing the Livewire validation path.

BusinessHours::openingHours() returns null. No record saved yet means null, not an empty OpeningHours. Every call site needs ?-> or a guard, and isOpenAt() on null is a fatal rather than a silent "closed".

Small package, zero dependents, eight stars. It's MIT, well-tested per its own CI badge, and small enough to read in an afternoon — but the ecosystem signal is genuinely thin (v1.0.1 is a supply-chain hardening release; the project itself takes its own dependency hygiene seriously). Pin the version in composer.json, review the diff on upgrades, and keep BusinessHours::toOpeningHoursArray() in your own code path so you can swap to a hand-rolled editor if maintenance stops.

Extension point, if you outgrow it: ->model(App\Models\ShopHours::class) swaps the model, provided it extends the package model. Use it to add scopes, casts, or observer registration in one place rather than sprinkling cache invalidation through your app — but override toOpeningHoursArray() carefully, because the panel's save validation depends on the shape it produces.

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.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator