spatie/holidays
Spatie's PHP package calculates public holidays for a country. Call Holidays::for('be')->get() to receive Holiday objects with localized names, CarbonImmutable dates, and types. Supports many countries via ISO 3166-1 alpha-2 codes or country instances. Requires PHP 8.4+.
The package is a plain PHP library — there is no service provider, no config file, and nothing to publish. Install it and you're immediately productive:
composer require spatie/holidays
Before you plan anything, verify the PHP constraint: this package requires PHP 8.4+. If your app is on Laravel 10/11 running PHP 8.2/8.3, that is an immediate blocker — check composer why-not php 8.4 in your project before proposing it in a PR.
The first thing to read is the Holidays facade-like entry point (Spatie\Holidays\Holidays), because every workflow goes through Holidays::for(...). The fluent chain is:
use Spatie\Holidays\Holidays;
Holidays::for(country: 'be', year: 2024, locale: 'fr', region: null)
->get(); // array of Holiday objects
The most common first use case in a Laravel app is not "list the holidays" — it's "is this date a working day?" for scheduling, billing, or delivery estimates:
if (Holidays::for('be')->isTodayHoliday()) {
// skip the daily digest mail, postpone the payout run, etc.
}
Before wiring country codes into your database or validation layer, confirm support with Holidays::has($code) — the supported list lives in src/Countries and is community-maintained, so "FR is supported but MC is not" is a realistic discovery.
Wrap it in one app-level service. Don't scatter Holidays::for(...) calls through controllers and jobs. Build a small HolidayCalendar class that resolves the tenant/user country, region, and locale once, and exposes the domain methods you actually need (working days, next closure, is-closed). This gives you a single place to add caching and a single place to swap the package out later.
Business-day math. The package gives you holidays; you still own the weekend logic:
public function workingDaysBetween(CarbonImmutable $from, CarbonImmutable $to): int
{
$holidays = Holidays::for($this->country)
->getInRange($from->toDateString(), $to->toDateString())
->reject(fn ($h) => in_array($h->type, [HolidayType::Observed, HolidayType::Banked]))
->map(fn ($h) => $h->date->toDateString())
->unique();
return $from->diffInDaysFiltered(
fn (CarbonImmutable $day) => $day->isWeekday() && ! $holidays->contains($day->toDateString()),
$to->addDay(),
);
}
getInRange() is inclusive and forgiving about argument order (the lower date becomes the start), so you can pass user input straight through. It also accepts shorthands ('2024', '2024-06') which is handy for report filters.
Caching. Holiday computation is deterministic for a given (country, year, region, locale), but it's not free — region classes and calendar lookup tables do date math on every call. Cache the serialized result per year and include the package version in the key so a composer update can't leave stale data behind:
Cache::remember("holidays:{$country}:{$region}:{$locale}:{$year}:" . PackageVersion::get(), now()->addYear(), fn () => ...);
Scheduling. Guard Laravel's scheduler and queued jobs with the calendar rather than hardcoding dates:
$schedule->command('payouts:run')->daily()->skip(fn () => $calendar->isClosedToday());
Note skip() accepts a closure, so this composes cleanly.
API / UI output. Holiday implements JsonSerializable, so $holidays can be returned directly from a controller (return $holidays; serializes to {"name":..,"date":..,"type":..,"region":..}). For a "next public holiday" banner, getUpcoming(3) is the exact call — cheap, already sorted, and works across a year boundary so you don't have to handle "December 31 looking for January 1" yourself.
Long weekends for travel/booking products. getLongWeekends() on a year-bound instance is a strong differentiator — it returns startDate, endDate, dayCount, and the contributing holidays[], which maps directly onto a "book a 4-day break" widget:
Holidays::for('de', 2024)->getLongWeekends(minDays: 3);
Region + locale are tenant data. For multi-country SaaS, store country_code, region_code, locale on the tenant, validate with Holidays::has() and Country::regions(), and pass them through Holidays::for(...). Country classes are cheap value objects; Belgium::make() and 'be' are interchangeable.
Default names are often not English. Each country declares a defaultLocale(); Holidays::for('be')->get()[0]->name is 'Nieuwjaar'. If you render English UI, you must pass locale: 'en' explicitly — and if no translation file exists for that locale, the original names silently come back. Don't assume translation; assert it in a test.
Lookup-table countries have a bounded year range. Islamic/Chinese/Indian/Nepali calendar countries use precomputed tables (roughly 2024–2037) and will throw an InvalidYear-style exception outside their supportedYearRange(). Any endpoint that accepts arbitrary years from a user must clamp or catch. Note that InvalidYear handling was fixed as recently as 1.19.0 — pin a minimum version if you depend on that behavior.
InvalidRegion is thrown, not returned. Passing a bad region to Holidays::for() blows up. Always intersect user-supplied regions with Country::regions() before calling.
Observed holidays can double-count. Many countries add a separate HolidayType::Observed entry alongside the real one (Christmas + "Christmas (Observed)"). If you're counting closures or billable days, filter by type or dedupe by date — otherwise a single day off counts twice. Similarly, decide deliberately whether Banked counts as a public holiday for your use case.
Timezone and time comparisons. $holiday->date is a CarbonImmutable date (country-local, typically midnight). Never compare it to now() with ->equalTo() or isToday() across timezones — compare ->toDateString() strings instead.
Testing requires freezing time. Methods like isTodayHoliday(), getUpcoming(), and default-year lookups read the clock. Use CarbonImmutable::setTestNow() in every test, and clean it up in afterEach. The package's own tests rely on Pest snapshots (formatDates($holidays) + toMatchSnapshot()); do the same in your app tests — it catches the "a country added a holiday in a minor release" drift loudly.
Data is community-sourced. Coverage and accuracy vary per country; a maintainer-driven PR may add, rename, or re-date holidays in a minor release. For anything financial or contractual, spot-check the output against the official source for your countries and pin the package version in composer.json. The dependents: 0 signal plus 409 stars means the ecosystem is thin — you'll be your own QA.
Extension points are limited but real. There is no runtime country registration: CountryRegistry::MAP is a constant, so you can't register() a class from your app. What you can do is subclass Country locally and pass the instance — Holidays::for(MyCountry::make()) works. Be aware the translation lookup resolves lang/{countryCode}/{locale}/holidays.json inside the package, so app-local countries won't get translations unless you post-process the names yourself. Adding a country "properly" means a PR upstream, not a config edit.
How can I help you explore Laravel packages today?