NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Opening Hours for Laravel

The OpeningHours facade

Everything goes through the OpeningHours facade over RoundlyConsulting\OpeningHours\OpeningHoursManager — the recommended way in. The manager is bound scoped (one memo per request or job) and aliased globally as OpeningHours (config facade_alias). The owner trait’s methods are shortcuts to the same calls — $clinic->setOpeningHours() is OpeningHours::sync($clinic):

use RoundlyConsulting\OpeningHours\DataTransferObjects\ExceptionData;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;

OpeningHours::sync($clinic, ['week' => ['monday' => ['09:00-17:00']]]); // replace the whole definition
OpeningHours::for($clinic)->isOpen();                                   // the query object
OpeningHours::edit($clinic)->closed('2026-12-24')->save();              // builder, optimistic save

// One exception at a time — locked, validated against the whole definition, never stale:
OpeningHours::exceptions($clinic)->closed('2026-12-24', label: 'Christmas Eve');
OpeningHours::exceptions($clinic)->open('2026-12-31', ['09:00-13:00']);
OpeningHours::exceptions($clinic, 'pickup')->add(ExceptionData::make('12-26', label: 'St Stephen'));
OpeningHours::exceptions($clinic)->all();                               // list<ExceptionData>, with ids
OpeningHours::exceptions($clinic)->remove($ruleId);                     // false for another calendar's rule

OpeningHours::delete($clinic);                                          // soft; the next sync restores it
OpeningHours::delete($clinic, 'pickup', force: true);                   // gone for good

Exception writes throw CalendarNotFoundException when the owner has no live calendar under the key — create it with sync() first. delete() is soft by default, so the next sync restores the calendar; force: true removes it for good, a soft-deleted one included.

Every method

MethodPurpose
for($owner, ?$calendar)The query object — same as $owner->openingHours().
calendar($owner, ?$calendar)The live ?Calendar header (null for an unsaved owner).
has($owner, ?$calendar)Whether the calendar exists.
edit($owner, ?$calendar)A CalendarBuilder seeded with the current definition.
sync($owner, $data, ?$calendar, ?$expectedRevision)Full replace; array input is parsed and validated first.
make($data, ?$timezone)An in-memory OpeningHours — no database.
exceptions($owner, ?$calendar)A CalendarExceptions handle — closed(), open(), add(), all(), remove() — for race-safe single-exception writes.
delete($owner, ?$calendar, force: false)Soft delete by default (the next sync restores it); force: true removes it for good, a soft-deleted one included; false when absent.
refresh($owner, ?$calendar)Bump the revision after raw SQL or a changed timezone hook.
validate($payload, ?$options)A ViolationList — nothing is written.
definitionData($calendar)The stored definition of a Calendar header as CalendarData, through memo and cache.
flushMemo()Clear the per-request memo.
fake()Swap in the recording OpeningHoursFake — see Testing.

Two classes named OpeningHours

The query object shares the facade’s short name — alias one where a file needs both:

use RoundlyConsulting\OpeningHours\Facades\OpeningHours as Hours;
use RoundlyConsulting\OpeningHours\OpeningHours;

Hours::for($clinic, 'pickup')->forWeek()->grouped();
Hours::has($clinic, 'pickup');
Hours::validate($request->input('hours', []))->toMessageBag('hours');

$preview = OpeningHours::make(['week' => ['saturday' => ['22:00-03:00']]], 'Europe/Bratislava');

Want the same API without static calls? See DI and actions.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.