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.
Manage the opening hours of a business from your Filament panel: a weekly 24-hour grid you drag ranges onto, exceptional closures, special-hours days, vacation periods and timezones. Everything is stored exactly in the format consumed by spatie/opening-hours, so your application can query the hours with the library you already know.

Delete on a focused range removes it.OpeningHours::create() eats: no export step, no proprietary format. Data
is validated by actually instantiating spatie's object before saving, so a
stored record can always be queried.canAccess() hook drives both the
navigation item and the route.| Package | Supported versions |
|---|---|
| PHP | 8.2, 8.3, 8.4, 8.5 |
| Laravel | 12, 13 |
| Filament | 4, 5 |
composer require laboiteacode/filament-business-hours
Publish and run the migration (one business_hours table):
php artisan vendor:publish --tag=filament-business-hours-migrations
php artisan migrate
Register the plugin in your panel provider:
use LaBoiteACode\FilamentBusinessHours\FilamentBusinessHoursPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(FilamentBusinessHoursPlugin::make());
}
That's it: a "Business hours" page appears in your panel navigation.
The page persists everything in one row of the package's business_hours
table (the default row unless configured otherwise), and that row holds the
exact definition format of spatie/opening-hours. There is no export or
conversion step: turning a stored record into a queryable object is a plain
OpeningHours::create() call, which the model wraps for you.
use LaBoiteACode\FilamentBusinessHours\Models\BusinessHours;
$openingHours = BusinessHours::openingHours(); // the "default" record
$openingHours = BusinessHours::openingHours('shop-paris'); // another record
// Null when no record has been saved yet under that key.
What you get back is a regular Spatie\OpeningHours\OpeningHours instance,
so the whole spatie query API
applies to the hours managed from your panel:
$openingHours->isOpenAt(new DateTime('2026-12-24 11:00')); // special hours applied
$openingHours->isClosedAt(now());
$openingHours->isOpenOn('monday');
$openingHours->nextOpen(now()); // when do we open next?
$openingHours->nextClose(now()); // until when are we open?
$openingHours->forDay('monday'); // the weekly ranges of a day
$openingHours->forDate(new DateTime('2026-12-25')); // a concrete date, exceptions applied
$openingHours->forWeek();
$openingHours->exceptionalClosingDates(); // vacations and closures, expanded
The timezone picked in the panel is part of the definition, so date and time
queries are interpreted in that timezone automatically. Labels typed in the
panel ("Christmas", "Summer break", ...) travel as spatie data and come
back through $openingHours->forDate(...)->data.
$record = BusinessHours::resolve('default');
$record->toOpeningHours(); // Spatie\OpeningHours\OpeningHours
$record->toOpeningHoursArray(); // the raw definition array for OpeningHours::create()
toOpeningHoursArray() returns a plain spatie definition: weekly hours,
exceptions (single dates, yearly recurring m-d dates, Y-m-d to Y-m-d
vacation ranges), the overflow flag and the timezone:
[
'monday' => ['09:00-12:00', '14:00-18:00'],
// ...
'exceptions' => [
'2026-12-24' => ['10:00-16:00'], // special hours
'2026-12-25' => ['hours' => [], 'data' => 'Christmas'], // closed, labelled
'01-01' => [], // closed every year
'2026-08-03 to 2026-08-16' => ['hours' => [], 'data' => 'Summer'], // vacation period
],
'timezone' => 'Europe/Paris',
]
Because the columns themselves are stored in spatie shape, nothing forces you through the Eloquent model. Any code that can read the table can assemble the definition on its own, for instance from a raw query or another service:
use Illuminate\Support\Facades\DB;
use Spatie\OpeningHours\OpeningHours;
$row = DB::table('business_hours')->where('key', 'default')->first();
$data = json_decode($row->hours, true)
+ ['exceptions' => json_decode($row->exceptions, true)];
if ($row->overflow) {
$data['overflow'] = true;
}
if ($row->timezone) {
$data['timezone'] = $row->timezone;
}
$openingHours = OpeningHours::create($data);
OpeningHours::create() parses the whole definition each time. On a page hit
on every request (a storefront header showing "open until 18:00", say), cache
the definition array rather than rebuilding it:
use Spatie\OpeningHours\OpeningHours;
$data = cache()->remember(
'business-hours:default',
now()->addHour(),
fn (): array => BusinessHours::resolve('default')->toOpeningHoursArray(),
);
$openingHours = OpeningHours::create($data);
Invalidate the cache key when the record is saved (a model observer on
BusinessHours, or simply a short TTL as above).
The door swings both ways: seeders, imports or your own admin code can write rows directly, and the page will pick them up on next load:
BusinessHours::create([
'key' => 'shop-paris',
'timezone' => 'Europe/Paris',
'hours' => [
'monday' => ['09:00-12:00', '14:00-18:00'],
'tuesday' => ['09:00-18:00'],
],
'exceptions' => [
'12-25' => ['hours' => [], 'data' => 'Christmas'],
],
]);
Exception entries the page cannot represent (recurring m-d to m-d periods,
structured data payloads, ...) are preserved verbatim across edits, so
advanced hand-written definitions survive the UI.
Saving is explicit: the Save header action persists the whole page (grid, exceptional dates, vacation periods and settings) as one record, after validating it against spatie/opening-hours itself. A date covered twice (say, an exceptional closure inside a vacation period) is rejected with a readable message.
The grid works on a per-day, 00:00 to 24:00 basis. A night business closing
at 2 AM is usually best modelled with two ranges (20:00-24:00 on Friday and
00:00-02:00 on Saturday). If you prefer spatie's overflow mode, enable
"Ranges past midnight" in the settings modal: overnight ranges such as
20:00-02:00 are then accepted, shown clamped to midnight in the grid, and
editable through the inline editor.
Publish the config file if you want application-wide defaults:
php artisan vendor:publish --tag=filament-business-hours-config
Every option can also be set per panel through the fluent API, which wins over the config file:
FilamentBusinessHoursPlugin::make()
// Navigation
->navigationLabel('Opening hours')
->navigationIcon('heroicon-o-clock')
->navigationGroup('Shop')
->navigationSort(10)
->registerNavigation(true)
->slug('opening-hours')
->cluster(Settings::class)
// Which row of the business_hours table this panel manages
->record('shop-paris')
// Swap the model for your own (must extend the package model)
->model(App\Models\ShopHours::class)
// Grid
->firstDayOfWeek('sunday') // 'monday' ... 'sunday'
->slotStep(30) // snapping, in minutes
// Timezones
->timezones(['Europe/Paris', 'Europe/Brussels'])
->defaultTimezone('Europe/Paris')
// Authorization
->canAccessUsing(fn (): bool => auth()->user()?->can('manageBusinessHours') ?? false);
Several panels can manage different records by giving each plugin instance its
own ->record() key (one page per panel).
By default the page is available to anyone who can access the panel. Restrict it with a Gate ability in the config:
'authorization' => [
'gate' => 'manageBusinessHours',
],
or with the ->canAccessUsing() closure shown above, which takes precedence.
Nothing reaches the database without being validated. On save, the page:
Spatie\OpeningHours\OpeningHours, so a stored record is queryable by
definition.Because the Livewire state is client-writable, hard caps also protect the server against crafted payloads: at most 100 ranges per day, 500 exceptional dates and vacation periods in total, 1100 days per vacation period, and 255 characters per label. Malformed state degrades gracefully instead of breaking the page render.
One table, one row per set of hours:
| Column | Type | Content |
|---|---|---|
key |
string | identifies the set (default, shop-paris, ...), unique |
timezone |
string / null | IANA identifier, null = application timezone |
overflow |
bool | spatie "overflow" flag (ranges past midnight) |
hours |
json | {"monday": ["09:00-12:00"], ...}, spatie day format |
exceptions |
json | spatie exceptions map (dates, m-d, Y-m-d to Y-m-d ranges) |
composer test
composer analyse
composer format
Please see CHANGELOG for more information on what has changed recently.
The MIT License (MIT). Please see License File for more information.
How can I help you explore Laravel packages today?