Convert and compare dates

The shared CalendarDate contract of Jalali, Hijri and Hebrew. How to convert a date between calendars, and how comparing and measuring works across calendars and time zones.

On this page
  1. One moment, three calendars
  2. Converting between calendars
    1. Pass a date to another class's make()
    2. From and to Gregorian
    3. Time zones decide the date
  3. The CalendarDate contract
  4. Comparing across calendars
  5. Differences across calendars
  6. Edge cases and limits

One moment, three calendars#

The three calendar classes (Jalali, Hijri, Hebrew) share one design. Each holds a single DateTimeImmutable and only views it in its own calendar. Converting between calendars never goes through text. It is the same moment read with another set of rules. Two things follow:

  • If you convert a date and convert it back, you get the same moment.
  • You can always compare or subtract dates from different calendars, because they are compared as moments.

The examples use this header:

<?php
require 'vendor/autoload.php';

use RtlyKit\Calendar\Hebrew;
use RtlyKit\Calendar\Hijri;
use RtlyKit\Calendar\Jalali;
use RtlyKit\Contracts\CalendarDate;
use RtlyKit\Exceptions\InvalidDateException;

$utc = new DateTimeZone('UTC');

Converting between calendars#

Pass a date to another class's make()#

Every make() accepts a CalendarDate, as well as a DateTimeInterface, an int timestamp, a string or null. Give the date you have to the class you want:

$j = Jalali::create(1405, 1, 1, 12, 0, 0, $utc);

echo Hijri::make($j)->format('Y/m/d F', 'en');    // 1447/10/02 Shawwal
echo Hebrew::make($j)->format('Y/m/d F');          // 5786/07/03 Nisan
echo Jalali::make(Hijri::make($j));                 // 1405/01/01 12:00:00

The time of day and the time zone are carried over. To show the result in another zone, pass a DateTimeZone as the second argument of make():

$tehran = new DateTimeZone('Asia/Tehran');

$h = Hijri::make($j, $tehran);
echo $h->getTimezone()->getName();      // Asia/Tehran
echo $h->getHour(), ':', $h->getMinute();   // 15:30   (12:00 UTC)
Same-class shortcut. Hijri::make($hijri) and Hebrew::make($hebrew) return the instance you pass, and ignore any time zone or variant argument. Jalali::make($jalali) returns it too, unless you pass a time zone. Then you get a copy in that zone. To change the variant of a Hijri date, go through the moment: Hijri::make($h->toGregorian(), null, HijriVariant::Tabular).

From and to Gregorian#

toGregorian() returns the inner DateTimeImmutable. Passing a DateTimeInterface to make() goes the other way. The static converters work on plain integers:

$g = new DateTimeImmutable('2026-03-21 12:00', $utc);

echo json_encode([
    Jalali::gregorianToJalali(2026, 3, 21),
    Hijri::gregorianToHijri(2026, 3, 21),
    Hebrew::gregorianToHebrew(2026, 3, 21),
]);
// [[1405,1,1],[1447,10,2],[5786,7,3]]

echo json_encode(Jalali::jalaliToGregorian(1405, 1, 1));   // [2026,3,21]
echo get_class($j->toGregorian());                         // DateTimeImmutable

The Gregorian year must be 1 to 9999. Each calendar covers only part of that span (Jalali -620 to 9377, Hijri 1 to 9665, Hebrew 3762 to 13759). So an early Gregorian date can throw InvalidDateException when you convert it to Hebrew or Hijri, even though the Gregorian date is valid:

try { Hebrew::make(new DateTimeImmutable('0001-01-01', $utc)); }
catch (InvalidDateException $e) { echo $e->getMessage(); }
// Date out of the supported Hebrew range (3762..13759): 3761

Time zones decide the date#

The calendar date of a moment depends on the zone you view it in. The same moment is still 1 Farvardin 1405 (21 March) in UTC. In Tehran, 3.5 hours ahead, it is already 2 Farvardin:

$instant = new DateTimeImmutable('2026-03-21 22:30', $utc);

echo Jalali::make($instant);            // 1405/01/01 22:30:00   (UTC)
echo Jalali::make($instant, $tehran);   // 1405/01/02 02:00:00   (Tehran, +03:30)

Pass a zone whenever the calendar day matters. Do not rely on the server default.

The CalendarDate contract#

RtlyKit\Contracts\CalendarDate is an interface that extends Stringable. Jalali, Hijri and Hebrew implement it. Type-hint it to write code that works with every calendar:

function describe(CalendarDate $d): string
{
    return sprintf('%s %d/%02d/%02d', (new ReflectionClass($d))->getShortName(),
        $d->getYear(), $d->getMonth(), $d->getDay());
}

foreach ([Jalali::class, Hijri::class, Hebrew::class] as $class) {
    echo describe($class::make($g)), "\n";
}
// Jalali 1405/01/01
// Hijri 1447/10/02
// Hebrew 5786/07/03
GroupMembers
Constructionmake(), now(), today(), create()
Calendar arithmetic (static)isValid(), isLeapYear(), daysInYear(), daysInMonth()
GettersgetYear(), getMonth(), getDay(), getHour(), getMinute(), getSecond(), getDayOfWeek(), getTimestamp(), getTimezone(), toGregorian()
Formattingformat(), toDateString(), toDateTimeString(), __toString()
Modificationadd/sub for Days, Hours, Minutes, Seconds, Months, Years; startOf/endOf for Day, Month, Year
Comparisoneq, ne, gt, gte, lt, lte, equals, isBefore, isAfter, between, isPast, isFuture, isToday
DifferencediffInDays(), diffInMonths(), diffInYears()

These hold for every implementation:

  • Instances are final and immutable. Modifiers return a new object.
  • Every method that takes a date, timestamp or amount throws only InvalidDateException for out-of-range input, never a TypeError or ValueError.
  • The interface declares format() with the pattern only. Each class adds its own optional parameters ($persianDigits for Jalali, $locale and $digits for Hijri, $locale for Hebrew). Through the interface you can call only format($pattern).
  • Weekday numbering is not the same everywhere. Jalali starts on Saturday. Hijri and Hebrew start on Sunday.
$i = new DateTimeImmutable('2025-10-07', $utc);   // a Tuesday
echo Jalali::make($i)->getDayOfWeek(), ' ', Hijri::make($i)->getDayOfWeek(), ' ', Hebrew::make($i)->getDayOfWeek();
// 3 2 2   (Jalali: Saturday = 0; the others: Sunday = 0)

Comparing across calendars#

All comparison methods take CalendarDate|DateTimeInterface and compare timestamps. You do not need to convert first:

$nowruz = Jalali::create(1405, 1, 1, 12, 0, 0, $utc);

var_dump($nowruz->eq(Hijri::make($nowruz)));                     // bool(true)
var_dump($nowruz->eq($nowruz->toGregorian()));                   // bool(true)
var_dump($nowruz->gt(Hebrew::create(5785, 1, 1, 0, 0, 0, $utc)));  // bool(true)
var_dump($nowruz->between(Hijri::create(1447, 8, 1, 0, 0, 0, $utc),
                          Hebrew::create(5787, 1, 1, 0, 0, 0, $utc))); // bool(true)

between($a, $b, $equal = true) accepts the bounds in either order. It includes them unless you pass false. Equality means the same moment. Jalali::create(1405, 1, 1) built in UTC is not equal to the same civil date built in Tehran, because they are 3.5 hours apart.

To sort a mixed list, order it by getTimestamp():

$list = [
    Hebrew::create(5786, 1, 1, 0, 0, 0, $utc),
    Jalali::create(1405, 1, 1, 0, 0, 0, $utc),
    Hijri::create(1447, 1, 1, 0, 0, 0, $utc),
];
usort($list, fn (CalendarDate $a, CalendarDate $b) => $a->getTimestamp() <=> $b->getTimestamp());

foreach ($list as $d) { echo $d::class, ' ', $d->toGregorian()->format('Y-m-d'), "\n"; }
// RtlyKit\Calendar\Hijri 2025-06-26
// RtlyKit\Calendar\Hebrew 2025-09-23
// RtlyKit\Calendar\Jalali 2026-03-21

Differences across calendars#

diffInDays() counts whole days between the two moments and ignores calendars. diffInMonths() and diffInYears() count whole months and years of the calendar of the object you call them on. The other date is first shown in that calendar. With $absolute = false, the sign is that of $this - $other.

$target = Hijri::create(1447, 9, 1, 0, 0, 0, $utc);   // 1 Ramadan 1447

echo $nowruz->diffInDays($target);          // 31
echo $nowruz->diffInDays($target, false);   // 31   ($nowruz is later than $target)
echo $target->diffInDays($nowruz, false);   // -31

echo $nowruz->diffInMonths($target);        // 1    (Jalali months)
echo Hijri::make($nowruz)->diffInMonths($nowruz->addMonths(3));  // 3  (Hijri months)
echo $nowruz->diffInYears(Hijri::create(1450, 1, 1, 0, 0, 0, $utc)); // 2

So "how many months until X" depends on the calendar you ask in. Choose the one your users think in. The units for each calendar are in Jalali, Hijri and Hebrew.

Edge cases and limits#

  • Ranges. Jalali -620..9377, Hijri 1..9665, Hebrew 3762..13759. Converting into a calendar whose range does not contain the moment throws InvalidDateException.
  • Hijri variant. A Hijri value converted to another calendar and back keeps the moment. The variant is the one of the target make() call (Umm al-Qura by default).
  • Good to know. Jalali uses the arithmetic 33-year rule, which matches the official calendar for the years 1206 to 1497. Hijri Umm al-Qura data for AH 1318 to 1500 matches the official KACST calendar (checked 2026-10-08). Outside the embedded table (AH 1300 to 1500), the arithmetic Tabular rules apply. The calendar pages have the details.
  • Errors. Catch InvalidDateException for calendar problems, or RtlyKit\Exceptions\RtlyKitException for any library error (Error handling).