Skip to the guide
All guides

Jalali calendar

The immutable Jalali (Persian, Solar Hijri) date class. Create and parse dates, format them with PHP date tokens, shift and snap them, compare them and measure the gap, with the exact year range and error behaviour.

app RTLY-Kit 0.2.0checked reading 12 minutes

On this page

What the class does

RtlyKit\Calendar\Jalali is an immutable date-time value in the Persian (Solar Hijri) calendar. Inside, it holds one DateTimeImmutable. So it has a time zone, a Unix timestamp and an exact Gregorian twin. The Jalali year, month and day are worked out from that moment. Every modifier returns a new object and the original never changes. The class is final and implements the shared CalendarDate contract from Convert and compare dates.

Before you start. Install the package (Installation). The examples assume this header:

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

use RtlyKit\Calendar\Jalali;
use RtlyKit\Exceptions\InvalidDateException;
use function RtlyKit\jdate;   // namespaced helper, same as Jalali::make()

Good to know. The conversion uses the arithmetic 33-year rule (leap years at residues 1, 5, 9, 13, 17, 22, 26, 30 modulo 33). We compared it with the official calendar of the Calendar Center at the University of Tehran. It gives the same Nowruz date and the same leap years for every year from 1206 to 1497. It also agrees with the astronomical definition (the year starts on the day when the spring equinox falls before noon, Tehran time) for every year from 1178 to 1502. Outside that window no official definition exists, so the library keeps the same fixed rule. For a legal deadline in a far past or future year, use the official publication.

Creating dates

From components: create()

create($year, $month, $day, $hour = 0, $minute = 0, $second = 0, $timezone = null) builds a date from Jalali parts. The date is checked against the real month length of that year. So 1404/12/30 (1404 is not a leap year) is rejected and 1403/12/30 is accepted. Without a time zone, the PHP default zone (date_default_timezone_get()) is used.

PHP
$tehran = new DateTimeZone('Asia/Tehran');
$d = Jalali::create(1404, 7, 15, 14, 30, 5, $tehran);

echo $d;                                   // 1404/07/15 14:30:05
echo $d->getTimestamp();                   // 1759834805
echo $d->toGregorian()->format('c');       // 2025-10-07T14:30:05+03:30

From anything: make()

Jalali::make($time = null, $timezone = null) accepts a DateTimeInterface, another CalendarDate (Hijri, Hebrew or Jalali), an int Unix timestamp, a string or null (now). The helper jdate() is a short alias.

PHP
$utc = new DateTimeZone('UTC');

echo Jalali::make(new DateTimeImmutable('2026-03-21 12:00', $utc)); // 1405/01/01 12:00:00
echo Jalali::make(0, $utc);                                        // 1348/10/11 00:00:00
echo jdate('2026-03-21')->format('l j F Y');                       // شنبه 1 فروردین 1405
echo Jalali::now();      // current instant in the default time zone
echo Jalali::today();    // today at 00:00:00

String semantics of make()

Strings are handled in this order:

  1. Persian and Arabic digits become English digits, and the string is trimmed. An empty or blank string throws InvalidDateException .
  2. If the string looks like Y/m/d or Y-m-d with a 3 or 4 digit year, and optionally H:i or H:i:s after a space or T , and the year is below 1700, it is read as a Jalali date.
  3. Every other string, including any year from 1700 on, goes to DateTimeImmutable and is read as Gregorian or free text ( '2025-10-07' , 'tomorrow' , '10 October 2025' ).
PHP
echo Jalali::make('1403/12/30');          // 1403/12/30 00:00:00
echo Jalali::make('۱۴۰۴/۰۷/۱۵');          // 1404/07/15 00:00:00   (Persian digits)
echo Jalali::make('1404-07-15 08:05');    // 1404/07/15 08:05:00
echo Jalali::make('2025-10-07')->format('Y/m/d');  // 1404/07/15   (Gregorian text)
echo Jalali::make('1700/01/01')->format('Y/m/d');  // 1078/10/12   (1700 or later is Gregorian)

Good to know. A string such as '0001-01-01' or '1500-06-01' is read as a Jalali date, because its year is below 1700. To pass a Gregorian date before the year 1700, build a DateTimeImmutable and give that object to make().

Invalid input always raises InvalidDateException:

PHP
foreach (['1404/12/30', '1404/13/01', '1404/07/15 25:00', '', 'garbage'] as $s) {
    try {
        Jalali::make($s);
    } catch (InvalidDateException $e) {
        echo "'$s': ", $e->getMessage(), "\n";
    }
}
// '1404/12/30': Invalid Jalali date: 1404/12/30
// '1404/13/01': Invalid Jalali date: 1404/13/1
// '1404/07/15 25:00': Invalid time: 25:0:0
// '': Unable to parse an empty date string.
// 'garbage': Unable to parse date: garbage

With a format: createFromFormat()

Jalali::createFromFormat($format, $time, $timezone = null) parses with the PHP DateTime::createFromFormat tokens (digits may be Persian) and reads the year, month and day as Jalali values. Gregorian limits such as "February has at most 29 days" do not apply. The Jalali calendar checks the result.

PHP
echo Jalali::createFromFormat('Y/m/d H:i', '1404/07/15 08:30', $tehran); // 1404/07/15 08:30:00
echo Jalali::createFromFormat('d-m-Y', '31-06-1404');                    // 1404/06/31 00:00:00

try {
    Jalali::createFromFormat('Y/m/d', 'abc');
} catch (InvalidDateException $e) {
    echo $e->getMessage();   // Unable to parse 'abc' with format 'Y/m/d'
}

Supported range and errors

Supported Jalali years run from Jalali::MIN_YEAR = -620 to Jalali::MAX_YEAR = 9377. That is the span that maps to Gregorian years 1 to 9999. The first supported day is -620/01/01 (0001-03-21) and the last is 9377/12/30 (9999-03-20). Every entry point checks the range and throws only InvalidDateException, never a TypeError, ValueError or DateMalformed* exception. This covers create(), make(), createFromFormat(), integer timestamps, add*(), sub*() and the static converters.

PHP
foreach ([[-621, 1, 1], [9378, 1, 1], [1404, 12, 30], [1404, 0, 1], [1404, 7, 31]] as [$y, $m, $d]) {
    try {
        Jalali::create($y, $m, $d);
    } catch (InvalidDateException $e) {
        echo $e->getMessage(), "\n";
    }
}
// Invalid Jalali date: -621/1/1
// Invalid Jalali date: 9378/1/1
// Invalid Jalali date: 1404/12/30
// Invalid Jalali date: 1404/0/1
// Invalid Jalali date: 1404/7/31

Time parts are checked too. Jalali::create(1404, 1, 1, 24, 0, 0) throws Invalid time: 24:0:0. An integer timestamp outside Gregorian years 1 to 9999 throws Timestamp out of the supported range: ....

Catch InvalidDateException for calendar problems only. Catch RtlyKit\Exceptions\RtlyKitException (or the marker interface RtlyKitThrowable) for every error of the library. See Error handling.

Reading values

MethodReturns
getYear(), getMonth(), getDay()Jalali year, month (1 to 12), day (1 to 31)
getHour(), getMinute(), getSecond()Time of day in the zone of the instance
getDayOfWeek()0 = Saturday (Shanbe) to 6 = Friday (Jomeh)
monthName()Persian name of the month, for example مهر
getTimestamp(), getTimezone()Unix timestamp and the DateTimeZone
toGregorian()The inner DateTimeImmutable
toDateString(), toDateTimeString(), __toString()Y/m/d, Y/m/d H:i:s, Y/m/d H:i:s

Weekday numbering

Jalali weeks start on Saturday. getDayOfWeek() and the w format token return 0 for Saturday and 6 for Friday. The N token is 1 (Saturday) to 7 (Friday). Hijri and Hebrew start on Sunday (0 = Sunday), like PHP's own w. Do not share weekday numbers across calendars. Compare moments instead.

PHP
$d = Jalali::create(1404, 7, 15);           // a Tuesday
foreach (range(0, 6) as $i) {
    $x = $d->addDays($i);
    echo $x->getDayOfWeek(), ':', $x->format('l'), ' ';
}
// 3:سه‌شنبه 4:چهارشنبه 5:پنجشنبه 6:جمعه 0:شنبه 1:یکشنبه 2:دوشنبه

Formatting

format($pattern = 'Y/m/d H:i:s', $persianDigits = false) works like PHP date() but in the Jalali calendar. Pass true as the second argument to get Persian digits. A backslash escapes the next character. Characters that are not tokens are copied as they are. A pattern longer than Jalali::MAX_FORMAT_LENGTH (256 bytes) throws InvalidDateException with the code input_too_long. A trailing backslash is dropped.

PHP
$d = Jalali::create(1404, 7, 15, 14, 30, 5, $tehran);

echo $d->format('l j F Y');            // سه‌شنبه 15 مهر 1404
echo $d->format('Y/m/d', true);        // ۱۴۰۴/۰۷/۱۵
echo $d->format('c');                  // 1404-07-15T14:30:05+03:30
echo $d->format('h:i A');              // 02:30 بعد از ظهر
echo $d->format('g:i a');              // 2:30 ب.ظ
echo $d->format('\Y: Y, \d: d');       // Y: 1404, d: 15
echo $d->format('t L z W N D');        // 30 0 200 41 4 س
TokenMeaning
Y yYear, 4 digits and 2 digits
m nMonth with and without leading zero
F MPersian month name (M is the same as F, because Persian month names have no short form)
d jDay with and without leading zero
l DWeekday name. D is the one-letter form (ش ی د س چ پ ج)
w NWeekday number: 0 to 6 and 1 to 7, Saturday first
zZero-based day of the Jalali year
t LDays in the month. 1 if the year is a leap year
H G h g24-hour and 12-hour clock, with and without leading zero
i sMinutes and seconds
a Aق.ظ/ب.ظ and قبل از ظهر/بعد از ظهر
SAlways empty (Persian has no ordinal suffix)
WISO-8601 week number of the Gregorian moment
cY-m-d\TH:i:sP with the Jalali date
rRFC 2822 string of the Gregorian moment (the RFC needs English Gregorian names)
U e T P p O Z I u vTaken from the inner moment (timestamp, zone, offset, microseconds, and so on)

Shifting and snapping

Every modifier returns a new instance. The time zone is kept.

MethodBehaviour
addDays() subDays()Whole days on the inner moment
addHours() addMinutes() addSeconds() and the sub* formsExact elapsed time
addMonths() subMonths()Calendar months. The day is clamped to the length of the target month
addYears() subYears()Same as 12 months per year. 30 Esfand in a leap year becomes 29 Esfand in a regular year
startOfDay() endOfDay()00:00:00 and 23:59:59 of the same day
startOfMonth() endOfMonth()First day 00:00:00. Last day 23:59:59
startOfYear() endOfYear()1 Farvardin 00:00:00. Last day of Esfand 23:59:59
PHP
$x = Jalali::create(1403, 12, 30);              // last day of a leap year
echo $x->addDays(1);                            // 1404/01/01 00:00:00
echo $x->subDays(30);                           // 1403/11/30 00:00:00
echo $x->addHours(30);                          // 1404/01/01 06:00:00
echo $x->addYears(1);                           // 1404/12/29 00:00:00  (clamped)
echo $x->subMonths(13);                         // 1402/11/30 00:00:00
echo Jalali::create(1404, 6, 31)->addMonths(1); // 1404/07/30 00:00:00  (clamped)

$m = Jalali::create(1404, 7, 15, 14, 30, 5);
echo $m->startOfMonth();   // 1404/07/01 00:00:00
echo $m->endOfMonth();     // 1404/07/30 23:59:59
echo $m->startOfYear();    // 1404/01/01 00:00:00
echo $m->endOfYear();      // 1404/12/29 23:59:59

Moving past the supported range, or by a huge amount, throws InvalidDateException. The date never wraps around:

PHP
try { Jalali::create(9377, 1, 1)->addYears(1); }
catch (InvalidDateException $e) { echo $e->getMessage(); }
// Jalali year out of the supported range: 9378

try { $x->addDays(PHP_INT_MAX); }
catch (InvalidDateException $e) { echo $e->getMessage(); }
// Cannot shift a date by 9223372036854775807 days: out of the supported range.

Comparing and measuring

The comparison methods (eq ne gt gte lt lte equals isBefore isAfter between isPast isFuture isToday) compare moments in time. They accept any CalendarDate or DateTimeInterface. Two values are equal only when they are the same moment, so the time zone matters, not just the printed date.

PHP
$a = Jalali::create(1405, 1, 1, 0, 0, 0, $tehran);
var_dump($a->eq(new DateTimeImmutable('2026-03-21 00:00', $tehran))); // bool(true)
var_dump($a->between(Jalali::create(1404, 1, 1), Jalali::create(1406, 1, 1))); // bool(true)
MethodMeaning
diffInDays($other, $absolute = true)Whole days, cut toward zero. With false the sign is that of $this - $other
diffInMonths($other, $absolute = true)Whole Jalali months. The day and the time of day count (15 Farvardin to 14 Tir is 2 months)
diffInYears($other, $absolute = true)Whole Jalali years, counted by anniversary
PHP
$jan = Jalali::create(1404, 1, 1);
$feb = Jalali::create(1404, 2, 1);
echo $jan->diffInDays($feb);                 // 31
echo $jan->diffInDays($feb, false);          // -31
echo Jalali::create(1404, 1, 15)->diffInMonths(Jalali::create(1404, 4, 14)); // 2
echo Jalali::create(1380, 5, 5)->diffInYears(Jalali::create(1404, 5, 4));    // 23
echo Jalali::create(1380, 5, 5)->diffInYears(Jalali::create(1404, 5, 5));    // 24

Static helpers

MethodReturns
Jalali::isValid($y, $m, $d)bool. False for an out-of-range year, month or day. It never throws
Jalali::isLeapYear($y)Leap if $y mod 33 is one of 1, 5, 9, 13, 17, 22, 26, 30
Jalali::daysInYear($y)365 or 366
Jalali::daysInMonth($y, $m)31 for months 1 to 6, 30 for 7 to 11, 29 or 30 for Esfand. 0 for an invalid month number
Jalali::gregorianToJalali($gy, $gm, $gd)[year, month, day]. The Gregorian year must be 1 to 9999
Jalali::jalaliToGregorian($jy, $jm, $jd)[year, month, day]. Day 1 to 31 is accepted for any month, and extra days roll forward
PHP
echo implode(',', array_filter(range(1399, 1410), Jalali::isLeapYear(...))); // 1399,1403,1408
echo Jalali::daysInMonth(1403, 12), ' ', Jalali::daysInMonth(1404, 12);        // 30 29
echo Jalali::daysInYear(1403), ' ', Jalali::daysInYear(1404);                  // 366 365
echo json_encode(Jalali::gregorianToJalali(2026, 3, 21));                      // [1405,1,1]
echo json_encode(Jalali::jalaliToGregorian(1405, 1, 1));                       // [2026,3,21]

try { Jalali::gregorianToJalali(10000, 1, 1); }
catch (InvalidDateException $e) { echo $e->getMessage(); }
// Gregorian date out of the supported range (years 1-9999): 10000-1-1

Practical notes

  • Time zones. A date made without a zone uses the PHP default zone. Pass a DateTimeZone to create() or make() when the result must not depend on server settings. Hour, minute and the date are read in the zone of the instance.
  • Day boundaries. The day changes at midnight in the zone of the instance, as everywhere in this library.
  • As a value. Instances are Stringable and safe to share or keep in arrays. Sort them by getTimestamp() .
  • Related. Convert and compare dates , Carbon macros and Holidays .