spatie/opening-hours
spatie/opening-hours lets you define business opening hours as a weekly schedule plus exceptions, then query whether you’re open or closed on a day or specific date and present times per day. It also works with Carbon via cmixin/business-time.
spatie/opening-hours is framework-agnostic: composer require spatie/opening-hours is the whole install. There is no service provider, config file to publish, facade, or migration — you supply the Laravel glue yourself. That is the single most important thing to internalize: treat it as a well-tested domain primitive (parse + query a schedule), not a Laravel feature package.
Look first at the OpeningHours class only. The other three classes (Time, TimeRange, OpeningHoursForDay) are value objects you consume, not build.
The fastest first use case is a single store schedule in config plus a tiny wrapper you can inject:
// config/opening-hours.php
return [
'timezone' => 'Europe/Brussels',
'schedule' => [
'monday to friday' => ['09:00-12:00', '13:00-18:00'],
'saturday' => ['09:00-12:00', '13:00-16:00'],
'sunday' => [],
'exceptions' => [
'01-01' => [],
'12-25' => [],
],
],
];
// app/Support/StoreHours.php
namespace App\Support;
use Spatie\OpeningHours\OpeningHours;
final class StoreHours
{
public function __construct(private readonly OpeningHours $hours) {}
public static function fromConfig(): self
{
return new self(OpeningHours::create(
config('opening-hours.schedule'),
config('opening-hours.timezone'), // input
config('app.timezone'), // output
));
}
public function isOpenNow(): bool
{
return $this->hours->isOpenAt(now());
}
public function __invoke(): OpeningHours
{
return $this->hours;
}
}
Bind it as a singleton in a service provider — the schedule is parsed on construction, so rebuilding it per call is pure waste:
$this->app->singleton(StoreHours::class, fn () => StoreHours::fromConfig());
Then app(StoreHours::class)->isOpenNow() is your "Open now" badge, and you've validated the whole integration in ten lines.
Per-record schedules (multi-location, multi-tenant). The package has no persistence layer, so store the raw array in a JSON column and cast it. Keep the array in exactly the shape OpeningHours::create() expects and never store the object itself.
// locations table: opening_hours json, timezone string
protected function openingHours(): Attribute
{
return Attribute::get(fn () => OpeningHours::create(
$this->opening_hours, // ['monday' => [...], 'exceptions' => [...]]
$this->timezone,
));
}
$location->opening_hours->isOpenAt($when);
Because construction is the expensive part, cache the built object on the model instance ($this->openingHours ??= ...) if you query it several times in one request.
Timezone split is the killer feature. Pass input (the schedule's business timezone) and output (the viewer's timezone) and the package converts inbound dates into business time and converts returned dates back to the viewer:
OpeningHours::create([
'timezone' => ['input' => 'America/New_York', 'output' => 'Europe/Oslo'],
'monday' => ['09:00-17:00'],
]);
For a booking system this means: store UTC timestamps, hand the package the user's local DateTime, and let nextOpen() hand you back a date in the user's zone. Always set at least the input timezone — leaving it null means the package trusts whatever timezone your DateTime happens to carry, which silently breaks the moment a user's timezone differs from the store's.
Status endpoint / "Open now" widget. currentOpenRange() is the one-call answer for the honest badge, because it covers "closes at" and "closed since":
$range = $hours->currentOpenRange($now);
if ($range) {
return ['open' => true, 'closes_at' => $range->end()]; // "18:00"
}
return [
'open' => false,
'reopens_at' => $hours->nextOpen($now)->toIso8601String(),
];
Note $range->start()/end() are time strings, not DateTime — join them with the date yourself for anything user-facing beyond a simple label.
Rendering a schedule table. Use forWeekCombined() so 09:00-18:00 appearing on five days becomes one row instead of five; forWeekConsecutiveDays() instead if you must not merge Monday with Sunday (it explicitly does not loop). Use forWeek() when you need one row per day.
@foreach ($hours->forWeekCombined() as $day => $data)
<tr>
<td>{{ $day }}</td> {{-- first day of the group --}}
<td>{{ $data['hours'] }}</td> {{-- OpeningHoursForDay, stringable --}}
<td>{{ implode(', ', $data['days']) }}</td>
</tr>
@endforeach
Validation rule. Wrap isOpenAt as a custom rule and use it in the FormRequest for reservations — this is where the package earns its keep, replacing hand-rolled weekday/interval logic:
class OpenAtRule implements ValidationRule
{
public function __construct(private readonly OpeningHours $hours) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $this->hours->isOpenAt(new \DateTimeImmutable($value))) {
$fail('We are closed at that time.');
}
}
}
Pair it with nextOpen() to offer "the next available time" in the same response — far better than a bare error.
Overflow for nightlife / late kitchens. If your range ends after midnight (20:00-03:00), enable 'overflow' => true in the definition; otherwise isOpenAt(01:00 Saturday) looks only at Saturday's ranges and reports closed. Prefer the explicit midnight split ('22:00-24:00' on Friday, '00:00-03:00' on Saturday) when you need per-day reporting to look right — overflow changes query semantics, not display.
Attach metadata instead of a parallel table. data is a free-form payload on a day, a range, or an exception. Use it for labels ("Last entry 17:30"), capacity, or service IDs so the schedule stays a single source of truth:
'tuesday' => [
['hours' => '09:00-12:00'],
['hours' => '19:00-21:00', 'data' => ['service' => 'evening', 'capacity' => 12]],
],
Holidays: precompute, don't filter. filters are closures run for every date lookup with no caching, so anything but trivial arithmetic is a latency trap. Compute Easter/Mother's Day once, write concrete entries into exceptions, and cache that array. Reserve live filters for genuinely unpredictable rules.
Testing. The package never calls now() — it only uses the DateTime you pass. So Carbon::setTestNow('2024-12-25 10:00') makes every assertion deterministic; you can unit-test "closed on Christmas" without touching the clock.
08:00-11:00 and 10:00-12:00. Run user input through OpeningHours::mergeOverlappingRanges($ranges) (or createAndMergeOverlappingRanges()) rather than catching the exception. Don't conflate this with 'overflow' => true, which is about ranges crossing midnight — use each for its own purpose.forDay('Maandag'). Map translated labels yourself and keep a canonical English key in the database.isOpenOn() accepts both day names and date strings. Ambiguity is a real risk with date-ish strings; pass a DateTimeInterface when you mean a date, and the lowercase day name when you mean a weekday.24:00 is not 00:00. Only 24:00 includes the midnight boundary; 00:00-07:00 on the next day leaves the 23:59–00:00 gap ambiguous. Be consistent across the split.'12-24 to 01-02'). If you need a New Year closure, prefer a specific dated exception ('2024-12-31 to 2025-01-01') and write a test for it.OpeningHours object (Cache::remember on the instance, queue payloads, serialize()) breaks the moment a closure is in filters. Cache the plain array and rebuild the object.filters array beats callables found inside exceptions. Recurring m-d entries and dated Y-m-d entries coexist — verify which one your business actually expects to win by writing a test with both present.nextOpen()/nextClose() a dead end. Check forWeek() sums to something non-empty (or handle a null return) before rendering "Reopens at…".02:00-03:00 simply doesn't exist on spring-forward day; if you're in a DST timezone, test the transition dates rather than assuming the schedule is stable year-round.StoreHours/Schedule service, a Cast/accessor for JSON storage, a validation rule, a Blade component, and an API resource. The package stays a leaf dependency, which is exactly what you want — no facade to fight, no config to publish, no version-locked Laravel coupling.cmixin/business-time. Skip it unless you already use business-time; the plain OpeningHours API covers 95% of day-to-day needs.How can I help you explore Laravel packages today?