All guides
No guide matches your search.
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.
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
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.
$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:30From 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.
$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:00String semantics of make()
Strings are handled in this order:
- Persian and Arabic digits become English digits, and the string is trimmed. An empty or blank string throws
InvalidDateException. - If the string looks like
Y/m/dorY-m-dwith a 3 or 4 digit year, and optionallyH:iorH:i:safter a space orT, and the year is below 1700, it is read as a Jalali date. - Every other string, including any year from 1700 on, goes to
DateTimeImmutableand is read as Gregorian or free text ('2025-10-07','tomorrow','10 October 2025').
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:
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: garbageWith 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.
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.
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/31Time 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
| Method | Returns |
|---|---|
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.
$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.
$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 س| Token | Meaning |
|---|---|
Y y | Year, 4 digits and 2 digits |
m n | Month with and without leading zero |
F M | Persian month name (M is the same as F, because Persian month names have no short form) |
d j | Day with and without leading zero |
l D | Weekday name. D is the one-letter form (ش ی د س چ پ ج) |
w N | Weekday number: 0 to 6 and 1 to 7, Saturday first |
z | Zero-based day of the Jalali year |
t L | Days in the month. 1 if the year is a leap year |
H G h g | 24-hour and 12-hour clock, with and without leading zero |
i s | Minutes and seconds |
a A | ق.ظ/ب.ظ and قبل از ظهر/بعد از ظهر |
S | Always empty (Persian has no ordinal suffix) |
W | ISO-8601 week number of the Gregorian moment |
c | Y-m-d\TH:i:sP with the Jalali date |
r | RFC 2822 string of the Gregorian moment (the RFC needs English Gregorian names) |
U e T P p O Z I u v | Taken 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.
| Method | Behaviour |
|---|---|
addDays() subDays() | Whole days on the inner moment |
addHours() addMinutes() addSeconds() and the sub* forms | Exact 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 |
$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:59Moving past the supported range, or by a huge amount, throws InvalidDateException. The date never wraps around:
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.
$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)| Method | Meaning |
|---|---|
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 |
$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)); // 24Static helpers
| Method | Returns |
|---|---|
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 |
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-1Practical notes
- Time zones. A date made without a zone uses the PHP default zone. Pass a
DateTimeZonetocreate()ormake()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
Stringableand safe to share or keep in arrays. Sort them bygetTimestamp(). - Related. Convert and compare dates , Carbon macros and Holidays .
Was this page helpful?