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

Opening Hours Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Overlapping ranges throw on construction. Admin-editable schedules will eventually contain 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.
  • Missing day ≠ empty day. Both mean closed in practice, but a missing key is easy to misread when diffing stored JSON; normalize on write.
  • Day names are lowercase English. No localization, no 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.
  • Year-crossing date-range exceptions are fragile ('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.
  • Filters can't be serialized or cached. Any attempt to cache the 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.
  • Exception/filter precedence: the first filter returning non-null wins, and the 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.
  • Guard "always closed" schedules. A store with no opening hours at all makes nextOpen()/nextClose() a dead end. Check forWeek() sums to something non-empty (or handle a null return) before rendering "Reopens at…".
  • DST edge cases: a range like 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.
  • Per-request cost. Construction parses and validates the whole week. In a loop over 200 locations you'll feel it — hoist construction out of the loop or memoize per model instance.
  • Extension points are thin, deliberately. Your leverage is the wrapper layer: a 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.
  • Carbon integration exists but is opt-in via cmixin/business-time. Skip it unless you already use business-time; the plain OpeningHours API covers 95% of day-to-day needs.
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.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel