sadekd/nova-opening-hours-field
A Laravel Nova field for managing opening hours. Add a weekly schedule editor to your Nova resources with per-day time ranges and JSON storage—ideal for restaurants, shops, and any business that needs editable opening hours.
The package is a single Nova form field. There is nothing to configure, no service provider to register manually, and no runtime code path — you install it, add a column, cast it, and put the field in a resource.
1. Install pinned to your Nova major. Do not float the constraint; ^4.0 and ~3.0.7 are hard-mutually-exclusive depending on which Nova line you are on.
# Nova 5
composer require sadekd/nova-opening-hours-field:^4.0
# Nova 4 (or staying put for now)
composer require sadekd/nova-opening-hours-field:~3.0.7
2. Publish Nova assets and clear caches. This is the step people skip and then file a bug about.
php artisan nova:publish
php artisan optimize:clear
3. Add the storage column.
Schema::table('venues', function (Blueprint $table) {
$table->json('opening_hours')->nullable()->after('name');
});
4. Cast it on the model. The field writes JSON; the cast is what makes it usable from PHP.
protected $casts = [
'opening_hours' => 'array',
];
5. Register the field in a Nova resource.
use Sadekd\NovaOpeningHoursField\NovaOpeningHoursField;
NovaOpeningHoursField::make('Opening Hours', 'opening_hours')
->rules(['nullable', 'array']),
Where to look first. Open vendor/sadekd/nova-opening-hours-field/src/ and read the field class — that gives you the exact FQCN, the component name, and any withMeta options the README does not spell out. Then open one saved record in tinker and dump the raw opening_hours value. Do that before writing any consumer code: the stored JSON shape is the contract you are building against, and the README describes the feature ("structured JSON") more than the schema.
First use case. A Venue (or Branch, Location, Store) model. An ops editor opens the venue in Nova, sets Monday–Friday 09:00–17:00, marks Saturday partially open with two ranges, marks Sunday closed, saves. You then read $venue->opening_hours in a controller or accessor. That is the whole loop, and it is genuinely the fastest path to getting hours out of a spreadsheet and into an admin panel.
Bridge to spatie/opening-hours for anything resembling logic. The field only captures; the Spatie library answers questions. If the field's stored shape matches Spatie's asStructuredData() shape (day => ['hours' => [['open' => 'HH:MM', 'close' => 'HH:MM']], 'data' => ...]), the handoff is one line. If it does not, write the normalizer once and hide it behind a method:
// app/Models/Venue.php
use Spatie\OpeningHours\OpeningHours;
public function openingHours(): OpeningHours
{
$raw = $this->opening_hours ?? [];
// Only needed if the field's shape differs from Spatie's structured data.
$normalized = collect($raw)->map(fn ($day) => [
'hours' => collect($day['times'] ?? $day['hours'] ?? [])
->map(fn ($slot) => [
'open' => $slot['opens'] ?? $slot['open'],
'close' => $slot['closes'] ?? $slot['close'],
])->all(),
])->all();
return OpeningHours::create($normalized);
}
Derive "open now" as an accessor, not a column. Never persist computed state; the field's JSON is the source of truth and caching a boolean will drift.
public function getIsOpenNowAttribute(): bool
{
return $this->openingHours()->isOpenNow();
}
Show it in Nova as a computed Text field so admins can sanity-check what the public site will say:
Text::make('Open Now', fn () => $this->openingHours()->isOpenNow() ? '✅ Open' : '⛔ Closed')
->onlyOnDetail(),
Keep timezone on the record, not in config. The field stores wall-clock times with no timezone. If venues span timezones, add a timezone string column next to opening_hours and pass it explicitly when evaluating:
$hours = $venue->openingHours(); // built from stored JSON
$hours->setTimezone($venue->timezone ?? config('app.timezone'));
Do this from day one, even if all venues are currently in one timezone — retrofitting it means auditing every call site.
Seed and factory data as plain arrays. You never need the Vue UI to create data; the JSON column is just JSON.
Venue::factory()->create([
'timezone' => 'Europe/Prague',
'opening_hours' => [
'monday' => ['hours' => [['open' => '09:00', 'close' => '17:00']]],
'tuesday' => ['hours' => [['open' => '09:00', 'close' => '17:00']]],
'wednesday' => ['hours' => [['open' => '09:00', 'close' => '17:00']]],
'thursday' => ['hours' => [['open' => '09:00', 'close' => '17:00']]],
'friday' => ['hours' => [['open' => '09:00', 'close' => '17:00']]],
'saturday' => ['hours' => [['open' => '09:00', 'close' => '12:00']]],
'sunday' => ['hours' => []],
],
]);
Add a custom validation rule rather than trusting the UI. The component enforces some things client-side; nothing stops malformed JSON from an API write, an import, or a future upgrade.
use Illuminate\Contracts\Validation\Rule;
class ValidOpeningHours implements Rule
{
public function passes($attribute, $value): bool
{
foreach ($value ?? [] as $day => $payload) {
foreach ($payload['hours'] ?? [] as $slot) {
$open = $slot['open'] ?? null;
$close = $slot['close'] ?? null;
if (!$open || !$close) return false;
if (strtotime($open) >= strtotime($close)) return false;
}
}
return true;
}
public function message(): string
{
return 'Each opening slot must close after it opens.';
}
}
Attach with ->rules(['nullable', 'array', new ValidOpeningHours]).
Cache per-location for public surfaces. Store-locator "Open now" badges evaluated per request across many venues is the one place this package's data touches a hot path. Cache by venue ID and bust on saved:
protected static function booted(): void
{
static::saved(fn (Venue $v) => Cache::forget("venue:{$v->id}:open-now"));
}
Expose a stable shape from your API resource so the mobile app and the store locator do not each re-implement the transform:
public function toArray($request): array
{
return [
'hours' => $this->opening_hours,
'timezone' => $this->timezone,
'open_now' => $this->openingHours()->isOpenNow(),
'opens_at' => $this->openingHours()->nextOpen()?->toIso8601String(),
];
}
Treat holiday exceptions as a separate table, not as an extra key inside the JSON. A venue_holiday_hours table joined and applied at evaluation time keeps the field's schema clean and keeps you on the upgrade path.
v4.0.0 is a Nova-5-only major, and the release notes say nothing else. The entire changelog entry is "Supports Laravel Nova 5." That means: (a) do not upgrade this package independently of the Nova 5 upgrade — it will fail composer install against a Nova 4 lockfile; (b) the notes are silent on whether the stored JSON schema, the field FQCN, or the option API changed. Diff one record before and after the upgrade. A compatibility-only bump usually means no schema change, but "usually" is not a guarantee you want to discover in production.
A blank widget after deploying is an asset-publish problem, not a code problem. Re-run php artisan nova:publish, then clear your browser cache / hard refresh. Nova field packages ship precompiled JS in dist/ that must be republished after every dependency change. If the field renders but saves nothing, check the network tab on the field's XHR before assuming the package is broken.
The field stores hours; it does not answer questions about them. No isOpenNow, no timezone conversion, no holiday handling, no overnight-span support in the field itself. If a stakeholder sees the admin UI and assumes "open now" is included, correct that in the same breath. All evaluation logic is yours — layer spatie/opening-hours for it.
Overnight spans need an explicit decision. A bar open 22:00–02:00 either cannot be entered, or is stored with close < open. Test this on the actual UI before promising the feature. If the UI rejects it, split into two entries and handle the merge in your evaluation layer, or store the span and document that consumers must detect close < open and roll the close time forward. Pick one and write it down — future-you will be the consumer.
null and [] are not the same thing. Decide early what "no hours set yet" means versus "closed permanently." array cast turns a JSON null into null and {} into []. Your Blade templates and API consumers need a single consistent answer, because the UI cannot express "not yet configured" — a blank field reads as "closed all week."
The JSON column is not searchable by day. If you ever need "find all venues open Monday morning," you are evaluating in PHP or denormalizing into an index. Do not plan for SQL predicates against the JSON blob; plan for a nightly denormalization or a small venue_hours_search table if the requirement appears.
Watch withMeta on this field. Nova serializes field metadata into the resource response. Adding large data payloads to the field via ->withMeta() bloats every detail and index response for that resource. Keep meta minimal.
Debugging checklist, in order: (1) tinker and dump $model->opening_hours — is the shape what you think? (2) php artisan nova:publish — did the asset get rebuilt? (3) check composer show sadekd/nova-opening-hours-field against composer show laravel/nova — are the majors matched? (4) browser devtools on the field XHR — is the save a no-op? (5) diff a fresh record against a pre-upgrade sample.
It is a single-maintainer package with 38 stars and zero dependents. Blast radius is tiny (admin-only, one column) but so is the support guarantee. The one durable artifact worth writing is a short internal note: which resources use the field, the exact FQCN for the installed version, and a sample of the stored JSON. That note is what turns "the package went dormant" from an archaeology project into an afternoon of forking.
Extension points, in order of preference: ->rules() and ->help() for guardrails; a computed Nova field for derived display; a custom Vue component only if you genuinely need UI changes; forking as the last resort. The package is thin enough that a fork is realistic — which is also why it is fine to depend on.
How can I help you explore Laravel packages today?