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

Exceptions and holidays

Exceptions override the weekly schedule on specific dates. Add, list and remove them one at a time through OpeningHours::exceptions($owner):

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

OpeningHours::exceptions($clinic)->closed('2026-12-24', label: 'Christmas Eve');
OpeningHours::exceptions($clinic)->closed('2026-12-24', '2026-12-26', label: 'Christmas');   // a span
OpeningHours::exceptions($clinic)->closed('01-01', label: 'New Year');                       // m-d = every year
OpeningHours::exceptions($clinic)->open('2026-12-31', ['09:00-13:00'], label: 'Short day');   // custom hours
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

Unlike edit()->…->save(), which rewrites the whole definition and throws StaleOpeningHoursException when someone saved in between, the handle locks the calendar row and adds just that one rule, validated against the whole definition — two admins adding holidays at once both succeed, and a builder still holding the old revision is refused rather than wiping the new exception.

An m-d date repeats every year; Y-m-d is one-off. Exception writes throw CalendarNotFoundException when the owner has no live calendar under the key — create it with sync() first. remove() only touches that calendar’s rules and returns false for any other id.

Which rule wins

On any date, the first match wins in this order:

  • One-off exceptions containing the date — the narrowest span wins.
  • Dynamic exception providers — the first non-null result in registration order.
  • Yearly exceptions — the narrowest span wins.
  • The schedule in effect — highest priority first, windowed before base.
  • Otherwise the day is closed.

A range belongs to the date it starts on, so an overnight range from the day before still runs into a closed exception day.

Querying exceptions

$hours->exceptionalClosingDates();                         // default: today … +365 days (within max_query_days)
$hours->exceptionalClosingDates('2026-01-01', '2026-12-31');
$hours->exceptionsBetween('2026-12-01', '2026-12-31');     // list<DayHours>

Easter-relative holidays

Movable holidays come from a DynamicExceptionProvider. EasterOffsetProvider ships built in — Good Friday is -2, Easter Monday 1, Ascension 39, Whit Monday 50 — and closes the day unless you pass ranges. Register providers on the owner or ad hoc on a query:

use RoundlyConsulting\OpeningHours\Dynamic\EasterOffsetProvider;

// On the owner:
public function openingHoursDynamicExceptions(): array
{
    return [
        new EasterOffsetProvider(-2, 'Good Friday'),
        new EasterOffsetProvider(1, 'Easter Monday', ['10:00-12:00']),
    ];
}

// Or ad hoc:
$hours->withDynamicExceptions(new EasterOffsetProvider(50, 'Whit Monday'));

Custom rules

Any rule you can express in code — the first Monday of every month, say — is a class implementing DynamicExceptionProvider. Return ExceptionData for the dates it covers and null elsewhere; the returned window is ignored and the exception applies to the date asked:

use RoundlyConsulting\OpeningHours\Contracts\DynamicExceptionProvider;
use RoundlyConsulting\OpeningHours\DataTransferObjects\ExceptionData;
use RoundlyConsulting\OpeningHours\ValueObjects\AbsoluteWindow;
use RoundlyConsulting\OpeningHours\ValueObjects\LocalDate;

final class FirstMondayInventory implements DynamicExceptionProvider
{
    public function exceptionFor(LocalDate $date): ?ExceptionData
    {
        return $date->day <= 7 && $date->weekday()->iso() === 1
            ? new ExceptionData(AbsoluteWindow::single($date), [], 'Inventory')
            : null;
    }
}

Providers are resolved per call and never cached. Easter is computed natively — no PHP extension required. There are no built-in country holiday calendars; add fixed-date holidays as yearly exceptions.

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.