fisharebest/ext-calendar
A pure-PHP implementation of PHP's calendar extension, providing cal_* functions and conversions between Julian day counts and Gregorian, Julian, Jewish, and French Republican calendars. Useful as a polyfill when ext-calendar is unavailable.
composer require fisharebest/ext-calendar
There is nothing to configure: no service provider, no config file, no published assets. It is a plain, dependency-free PHP library whose whole purpose is to be a pure-PHP replacement for ext/calendar (which most shared hosts no longer compile in). Recent releases require PHP 8.x; check composer.json for the exact floor.
Where to look first
src/CalendarInterface.php — the contract every calendar implements.src/Calendar.php — the abstract base with the shared helpers (daysInMonth(), monthsInYear(), leapYear(), dayOfWeek()).Gregorian, Julian, French, Jewish, Persian, Arabic.The one idea that makes the whole package click: every conversion goes through the Julian Day Number, an integer day count. You never convert calendar A directly to calendar B — you convert A → JD → B. Each concrete class exposes the generic pair calendarToJd() / jdToCalendar() plus a named convenience pair (gregorianToJd() / jdToGregorian(), jewishToJd() / jdToJewish(), and so on).
First use case — you have a Gregorian date in the database and need to show the Hijri equivalent, or you are importing GEDCOM date escapes (@#DJULIAN@, @#DHEBREW@, @#DFRENCH R@) and need a sortable key:
use Fisharebest\ExtCalendar\Gregorian;
use Fisharebest\ExtCalendar\Arabic;
$jd = (new Gregorian())->gregorianToJd(2024, 3, 25); // int
[$y, $m, $d] = (new Arabic())->jdToCalendar($jd); // [1445, 9, 15]
Store the JD, not the string. The highest-leverage decision is to add an indexed INT column (date_jd) alongside the original human-entered date. Sorting, range queries, "born before", and age arithmetic all collapse to integer comparison. Render into whatever calendar the viewer's locale wants at display time.
Build a small factory/registry. Map the GEDCOM escape or a locale calendar code to an instance, and memoize it — the objects are stateless and cheap, but re-instantiating them in a render loop is pure waste:
final class Calendars
{
private static array $instances = [];
public static function for(string $code): CalendarInterface
{
return self::$instances[$code] ??= match ($code) {
'gregorian' => new Gregorian(),
'julian' => new Julian(),
'hebrew' => new Jewish(),
'french' => new French(),
'persian' => new Persian(),
'hijri' => new Arabic(),
};
}
}
In Laravel, bind this as a singleton in a service provider if you want it injectable and swappable in tests.
Validate before you convert. Parsing a user string like 30 February 1700 into a silently wrong JD is the classic bug. Gate every calendarToJd() call behind daysInMonth($y, $m) and 1 <= $m <= monthsInYear($y), and reject with a message that names the calendar.
Round-trip everything in tests. The cheapest possible correctness harness, and it catches leap-month and epoch bugs instantly:
foreach ($samples as [$y, $m, $d]) {
$jd = $cal->calendarToJd($y, $m, $d);
self::assertSame([$y, $m, $d], $cal->jdToCalendar($jd));
}
Keep timezones out of the library. The package is date-only. Convert a DateTimeImmutable to Y-m-d in the intended timezone first, then hand those three integers to calendarToJd(). Do not pass a Unix timestamp through unixtojd(); midnight boundaries and DST will shift the day.
Iterate, don't assume. Month loops must be for ($m = 1; $m <= $cal->monthsInYear($y); $m++) and day loops must use daysInMonth($y, $m). Twelve months, 30 days, and 31 days are all wrong assumptions for at least three of these calendars. For a month-grid UI:
$firstJd = $cal->calendarToJd($y, $m, 1);
$offset = $cal->dayOfWeek($firstJd); // verify 0=Sunday vs 0=Monday
$dayCount = $cal->daysInMonth($y, $m);
Cross-calendar comparisons are integer math. "Days between these two Hebrew dates", "is this Julian date before that Persian one" — convert both to JD and subtract. Age and anniversary logic becomes trivial and exact.
Separate formatting from conversion. Use intl / ICU skeletons for month names and digit shaping, keyed by calendar name, and use this package only for the numeric conversion. Don't try to make it do localization; it won't.
Extension point. To add Ethiopian, Coptic, or Bahá'í, extend the abstract Calendar (or implement CalendarInterface directly) and supply calendarToJd() / jdToCalendar() plus the epoch offset and leap rule. Everything downstream only sees CalendarInterface, so your pipeline needs no changes.
Gregorian is proleptic Gregorian and Julian is proleptic Julian. There is no automatic switch on 1582-10-15, and the library cannot tell you which one a historical date should use — that is a policy decision you encode.$month <= 12 check is a latent bug; always consult monthsInYear() and daysInMonth().dayOfWeek()'s origin. Whether 0 means Sunday or Monday varies by library and has changed across versions in the wider ecosystem. Assert it in a test against a known date (2024-01-01 was a Monday) rather than reading it from memory.composer.json ("fisharebest/ext-calendar": "6.*") and keep your own round-trip and anchor-date tests in CI, so an upstream correction to a leap rule shows up as a red build rather than a silent data shift.How can I help you explore Laravel packages today?