azuyalabs/yasumi
Yasumi is a PHP library for retrieving holiday dates and names across many countries and regions. Calculation- and rule-driven, it needs no database and offers a simple API to query, list, and check holidays for any year.
Install with Composer — there is nothing to publish, no migrations, no service provider:
composer require azuyalabs/yasumi
Sanity-check the runtime first: Yasumi requires PHP 8.2+, so on an older Laravel app composer require will fail or silently resolve an old major. Run composer why-not azuyalabs/yasumi if you're unsure.
Where to look first, in order:
https://www.yasumi.dev — the real documentation (the README is an advert; the docs site has the provider list, filters, and custom-provider recipes).Yasumi::getProviders() — introspect at runtime rather than hardcoding provider names in your head.examples/ directory in the repo for copy-paste starting points.First use case: "is this a working day?" This is 90% of what Laravel apps actually want Yasumi for — delivery estimates, SLA clocks, payout dates, cron skipping.
use Yasumi\Yasumi;
$holidays = Yasumi::create('USA', 2026);
$holidays->isHoliday(new DateTimeImmutable('2026-07-03')); // bool
$holidays->isWorkingDay(new DateTimeImmutable('2026-07-03')); // false if weekend or holiday
$holidays->isWeekend(new DateTimeImmutable('2026-07-04'));
// Enumerate the year
foreach ($holidays as $holiday) {
echo $holiday->getName().' — '.$holiday->format('Y-m-d').PHP_EOL;
}
// Look up a specific one by its internal key
$holiday = $holidays->getHoliday('independenceDay');
Then map your app's locale/tenant country to a provider with Yasumi::createByISO3166_2('NL', 2026) instead of matching provider strings by hand. Provider objects are ArrayIterator-based, so foreach, count(), and iterator_to_array() all just work.
Don't scatter Yasumi::create() calls through controllers. Create one small service and resolve it from the container:
// app/Services/HolidayCalendar.php
namespace App\Services;
use Carbon\CarbonInterface;
use Yasumi\Yasumi;
use Yasumi\Provider\AbstractProvider;
class HolidayCalendar
{
/** @var array<string, AbstractProvider> */
private array $memo = [];
public function __construct(
private readonly string $provider, // e.g. 'Netherlands'
private readonly string $locale = 'en_US',
) {}
public function for(int $year): AbstractProvider
{
return $this->memo[$year] ??= Yasumi::create($this->provider, $year, $this->locale);
}
public function isHoliday(CarbonInterface $date): bool
{
return $this->for($date->year)->isHoliday($date->toDateTimeImmutable());
}
public function isWorkingDay(CarbonInterface $date): bool
{
return $this->for($date->year)->isWorkingDay($date->toDateTimeImmutable());
}
}
Resolve it per-tenant/per-user, e.g. app(HolidayCalendar::class) for a single-country app, or a factory method taking the user's country. Keep the locale→provider mapping in config/holidays.php so ops can fix a country without a deploy.
A provider computes every holiday for the year in its constructor. Building one per request per tenant is wasteful; building one per isHoliday() call is pathological.
$names = Cache::remember(
"holidays:{$provider}:{$year}:{$locale}",
now()->addDay(),
fn () => Yasumi::create($provider, $year, $locale)->getHolidayNames() // key => name
);
$dates = Cache::remember(
"holidays-dates:{$provider}:{$year}",
now()->addDay(),
fn () => collect(Yasumi::create($provider, $year)->getHolidays())
->map(fn ($h) => $h->format('Y-m-d'))
->flip()
->all()
);
Cache plain scalars (date strings, name maps), not Holiday objects — serialization survives, but the underlying rules change between releases and stale objects hide that. Holiday definitions do get retro-fixed upstream, so a 24h TTL (or a scheduled nightly warm) is a reasonable compromise.
public function addBusinessDays(CarbonInterface $start, int $days): CarbonInterface
{
$date = $start->copy();
while ($days > 0) {
$date->addDay();
if ($this->isWorkingDay($date)) {
$days--;
}
}
return $date;
}
Looping years at the boundary is the only subtlety: a date near 31 December needs next year's provider. Either keep a two-year memo or guard with if ($date->month === 12 && $date->day > 20) and pre-warm the next year.
Payroll, invoicing, and UI display have different rules. Yasumi classifies holidays as official, observed, bank, season, or other:
use Yasumi\Holiday;
$official = Yasumi::create('UnitedKingdom', 2026)->filter('type', Holiday::TYPE_OFFICIAL);
$bank = Yasumi::create('UnitedKingdom', 2026)->filter('type', Holiday::TYPE_BANK);
Use official for legal/contractual logic (interest accrual, statutory pay), bank for settlement/payment rails, and show everything to end users. Note filter() returns a plain array — you lose the isHoliday()/isWorkingDay() helpers on the result, so filter late.
A working_day rule pays for itself immediately in booking/scheduling forms:
Validator::extend('working_day', fn ($attr, $value, $params, $validator) => app(HolidayCalendar::class)
->isWorkingDay(Carbon::parse($value)));
For a JSON feed, project deliberately rather than json_encode($holiday):
return collect($provider->getHolidays())->map(fn ($h) => [
'key' => $h->getName(), // or build a key map from getHolidayNames()
'name' => $h->getName(),
'date' => $h->format('Y-m-d'),
'type' => $h->getType(),
])->values();
if (! app(HolidayCalendar::class)->isWorkingDay(today())) return;Yasumi::next($provider, $locale) — cheaper than iterating years yourself.Freeze time as usual; Yasumi is deterministic per (provider, year, locale), so snapshot tests are ideal:
public function test_2026_list_is_stable(): void
{
$this->assertSame(
['2026-01-01', '2026-04-03', /* ... */],
array_values(array_map(
fn ($h) => $h->format('Y-m-d'),
iterator_to_array(Yasumi::create('Germany', 2026))
))
);
}
A snapshot test is your canary when bumping the package — treat changes as contract changes, not as noise to re-bless.
For company-specific days or an unsupported region, extend AbstractProvider (or Christian, which gives you calculateEaster() and friends), implement initialize(), and addHoliday():
$this->addHoliday(new Holiday(
'foundersDay',
['en_US' => "Founders' Day", 'nl_NL' => 'Oprichtersdag'],
new DateTime('2026-05-14', $this->timezone),
$this->locale,
Holiday::TYPE_OTHER,
));
Also override $timezone and the weekend definition if the country isn't Sat/Sun.
getHoliday() throws for unknown keys on current versions. The README's if ($x !== null) snippet is out of date — recent changelogs add a HolidayNotFoundException. Guard with array_key_exists($key, $provider->getHolidayNames()) or wrap in try/catch.Carbon timestamp in America/New_York compared against a Europe/Amsterdam provider can land on the wrong calendar day near midnight. Normalise with ->toDateString() or ->setTimezone($provider->timezone) before comparing.Holiday extends DateTime, it is not Carbon. You get format(), getName(), getType() — not isSameDay() or addWeekdays(). Use Carbon::instance($holiday) when you need the fluent API, and avoid mutating the object (it's mutable DateTime; mutating a provider's holiday corrupts the instance for the rest of the request).locale -a). Containers frequently ship without generated locales and you'll silently get English. Fallback is en_US.date => single holiday; iterate.newYearsDay, goodFriday, christmasDay) and not perfectly uniform across providers, and provider identifiers are specific (Czechia vs Czech Republic is an explicit alias, per the changelog). Always discover via getHolidayNames() / getProviders() and pin the strings in tests.TYPE_OFFICIAL, pin the version and review the changelog before upgrading.isWeekend() follows that, so don't re-implement $date->isWeekend() from Carbon alongside it — you'll disagree with yourself.Yasumi::create() per row is a classic N+1-in-CPU-time. Resolve the calendar once per request job and memoize per year.changelog/unreleased.md before bumping, keep the snapshot test, and don't allow ^2 carets on a repo where a holiday name change hits customer-facing invoices.How can I help you explore Laravel packages today?