On this page
The problem with DateTime
PHP has two classes for dates, DateTime and DateTimeImmutable. Both use the Gregorian calendar, and there is no option for another one. If you give them a Shamsi date, they read it as a Gregorian date.
This program shows what happens. It reads two Shamsi dates with createFromFormat().
<?php
// DateTime knows only the Gregorian calendar. A Shamsi date goes in as if it were Gregorian.
// Run with: php gregorian-only.php
$day = DateTimeImmutable::createFromFormat('Y-m-d', '1403-12-30', new DateTimeZone('UTC'));
echo 'Read as Gregorian: ', $day->format('Y-m-d'), PHP_EOL;
// Day 30 of month 12 exists in a Shamsi leap year, so a person might write it.
// DateTime has no idea what it means. Month 13 does not exist, so it rolls over.
$odd = DateTimeImmutable::createFromFormat('Y-m-d', '1403-13-05', new DateTimeZone('UTC'));
echo 'Month 13 rolls over to: ', $odd->format('Y-m-d'), PHP_EOL;
$errors = DateTimeImmutable::getLastErrors();
echo 'Warnings: ', $errors['warning_count'] ?? 0, PHP_EOL;Read as Gregorian: 1403-12-30
Month 13 rolls over to: 1404-01-05
Warnings: 1The first line looks fine, but it means 30 December of the year 1403 AD. That is a different day from 30 Esfand 1403. The second date has month 13. DateTime does not refuse it. It moves the date into January of the next year. The only sign is one warning, and you must ask for it with getLastErrors(). The PHP manual says that the parser uses an overflow mechanism (opens in a new tab) when a value is out of range.
So DateTime cannot hold a Shamsi date. You need another tool.
What intl gives you
The intl extension is built on ICU, a library that knows many calendars. One of them is the Persian calendar. IntlCalendar can read a moment and tell you the Shamsi year, month and day.
<?php
// The intl extension has a Persian calendar. It turns a Gregorian moment into Shamsi fields.
// Run with: php intl-persian.php
$tz = new DateTimeZone('Asia/Tehran');
$moment = new DateTimeImmutable('2025-03-21 12:00:00', $tz); // 1 Farvardin 1404
$cal = IntlCalendar::createInstance($tz, 'fa_IR@calendar=persian');
$cal->setTime($moment->getTimestamp() * 1000); // intl uses milliseconds
printf(
"Year %d, month %d, day %d\n",
$cal->get(IntlCalendar::FIELD_YEAR),
$cal->get(IntlCalendar::FIELD_MONTH) + 1, // intl counts months from 0
$cal->get(IntlCalendar::FIELD_DAY_OF_MONTH)
);
// In the fa_IR locale the week starts on Saturday. In intl, Sunday is 1 and Saturday is 7.
echo 'First day of the week: ', $cal->getFirstDayOfWeek(), PHP_EOL;
// To print a Shamsi date, give the formatter the calendar object.
$en = new IntlDateFormatter('en_US', IntlDateFormatter::LONG, IntlDateFormatter::NONE, $tz, IntlCalendar::createInstance($tz, 'en_US@calendar=persian'));
echo $en->format($moment), PHP_EOL;
// A locale string alone is not enough: the formatter then uses the Gregorian calendar.
$wrong = new IntlDateFormatter('en_US@calendar=persian', IntlDateFormatter::LONG, IntlDateFormatter::NONE, $tz);
echo 'Locale string only: ', $wrong->format($moment), PHP_EOL;
// Names in Persian need the full ICU locale data. Check that the server has it.
$fa = new IntlDateFormatter('fa_IR', IntlDateFormatter::FULL, IntlDateFormatter::NONE, $tz, $cal);
echo 'fa_IR output: ', $fa->format($moment), PHP_EOL;
echo 'ICU version: ', INTL_ICU_VERSION, PHP_EOL;Year 1404, month 1, day 1
First day of the week: 7
Farvardin 1, 1404
Locale string only: March 21, 2025 AD
fa_IR output: Friday, Farvardin 1, 1404 AP
ICU version: 78.1I ran this on PHP 8.3 with ICU 78.1. Four details are easy to miss:
intlcounts months from 0, so you must add 1.- In the
fa_IRlocale, the week starts on Saturday.getFirstDayOfWeek()returns 7, because Sunday is 1. - To print a Shamsi date, give
IntlDateFormattera calendar object. In my test, the locale stringen_US@calendar=persianalone gave a Gregorian date. - Names in Persian need the full locale data of ICU. In my test container,
fa_IRprinted English month names. Run a check like the last two lines on your own server before you rely on it.
The rules of the calendar
The first six Shamsi months have 31 days. The next five have 30 days. The last month, Esfand, has 29 days, or 30 in a leap year. I asked intl for the lengths of 1403 and 1404.
<?php
// Asks intl for the length of each Shamsi month, in a leap year and in a common year.
// Run with: php month-lengths.php
$tz = new DateTimeZone('UTC');
$cal = IntlCalendar::createInstance($tz, 'en_US@calendar=persian');
foreach ([1403, 1404] as $year) {
$cal->clear();
$cal->set($year, 0, 1); // year, month (from 0), day
$lengths = [];
for ($month = 0; $month < 12; $month++) {
$cal->set(IntlCalendar::FIELD_MONTH, $month);
$lengths[] = $cal->getActualMaximum(IntlCalendar::FIELD_DAY_OF_MONTH);
}
echo $year, ': ', implode(' ', $lengths), PHP_EOL;
}1403: 31 31 31 31 31 31 30 30 30 30 30 30
1404: 31 31 31 31 31 31 30 30 30 30 30 29Leap years follow a cycle of 33 years that has 8 leap years. ICU decides this with one line. The source file persncal.cpp (opens in a new tab) says that a year is a leap year when (year * 25 + 11) % 33 < 8. The same file adds a table of corrections that starts at year 1502. Before that year, the plain rule is enough.
I prefer a list of leap positions in the cycle, because it is easy to read. A year is a leap year when the rest of its division by 33 is 1, 5, 9, 13, 17, 22, 26 or 30. A short test shows that the list and the ICU rule agree for every year from 1 to 1501.
A small converter
With a leap rule, you can write a converter. This file has four small functions. It uses one known day as an anchor: 1 Farvardin 1404 is 21 March 2025, which the first intl program confirmed.
<?php
// A small Shamsi converter that uses the 33-year leap rule. Other files in this folder load it.
const LEAP_RESIDUES = [1, 5, 9, 13, 17, 22, 26, 30]; // year mod 33 for a leap year
function isShamsiLeap(int $year): bool
{
return in_array($year % 33, LEAP_RESIDUES, true);
}
function daysInShamsiMonth(int $year, int $month): int
{
if ($month <= 6) {
return 31;
}
if ($month <= 11) {
return 30;
}
return isShamsiLeap($year) ? 30 : 29;
}
// Days from 1 Farvardin of year 1 to 1 Farvardin of $year.
function daysBeforeYear(int $year): int
{
$years = $year - 1;
$leaps = intdiv($years, 33) * count(LEAP_RESIDUES);
foreach (LEAP_RESIDUES as $residue) {
if ($residue <= $years % 33) {
$leaps++;
}
}
return 365 * $years + $leaps;
}
// The anchor: 1 Farvardin 1404 is 21 March 2025 (Gregorian).
const ANCHOR_YEAR = 1404;
const ANCHOR_DATE = '2025-03-21';
/** @return array{int,int,int} Year, month and day. */
function gregorianToShamsi(string $date): array
{
$anchor = new DateTimeImmutable(ANCHOR_DATE, new DateTimeZone('UTC'));
$target = new DateTimeImmutable($date, new DateTimeZone('UTC'));
$diff = (int) $anchor->diff($target)->format('%r%a'); // days from the anchor
$year = ANCHOR_YEAR + (int) floor($diff / 365.2425);
// Move the guess until the day falls inside the year.
while (daysBeforeYear($year) - daysBeforeYear(ANCHOR_YEAR) > $diff) {
$year--;
}
while (daysBeforeYear($year + 1) - daysBeforeYear(ANCHOR_YEAR) <= $diff) {
$year++;
}
$dayOfYear = $diff - (daysBeforeYear($year) - daysBeforeYear(ANCHOR_YEAR)); // from 0
$month = 1;
while ($dayOfYear >= daysInShamsiMonth($year, $month)) {
$dayOfYear -= daysInShamsiMonth($year, $month);
$month++;
}
return [$year, $month, $dayOfYear + 1];
}The function daysBeforeYear() counts the days from year 1 to the start of a year. To convert a date, gregorianToShamsi() counts the days from the anchor and finds the year. Then it finds the month and the day inside that year.
A converter is only useful when it is right, so I checked it against intl. This program converts every day from 1 January 1900 to 31 December 2100 and compares the result with IntlCalendar.
<?php
// Checks the converter in shamsi.php against the intl extension, for every day from 1900 to 2100.
// Run with: php check-with-intl.php
require __DIR__ . '/shamsi.php';
[$y, $m, $d] = gregorianToShamsi('2025-03-21');
echo "2025-03-21 is $y-$m-$d", PHP_EOL;
[$y, $m, $d] = gregorianToShamsi('2025-03-20');
echo "2025-03-20 is $y-$m-$d", PHP_EOL;
// Check every day from 1900 to 2100 against intl.
$tz = new DateTimeZone('UTC');
$cal = IntlCalendar::createInstance($tz, 'en_US@calendar=persian');
$day = new DateTimeImmutable('1900-01-01 12:00:00', $tz);
$end = new DateTimeImmutable('2100-12-31 12:00:00', $tz);
$checked = 0;
$wrong = [];
for (; $day <= $end; $day = $day->modify('+1 day')) {
$cal->setTime($day->getTimestamp() * 1000);
$expected = [
$cal->get(IntlCalendar::FIELD_YEAR),
$cal->get(IntlCalendar::FIELD_MONTH) + 1,
$cal->get(IntlCalendar::FIELD_DAY_OF_MONTH),
];
$mine = gregorianToShamsi($day->format('Y-m-d'));
$checked++;
if ($expected !== $mine) {
$wrong[] = $day->format('Y-m-d') . ' mine=' . implode('-', $mine) . ' intl=' . implode('-', $expected);
}
}
echo 'First day: ', implode('-', gregorianToShamsi('1900-01-01')), PHP_EOL;
echo 'Last day: ', implode('-', gregorianToShamsi('2100-12-31')), PHP_EOL;
echo "Days checked: $checked", PHP_EOL;
echo 'Days that differ: ', count($wrong), PHP_EOL;
foreach (array_slice($wrong, 0, 3) as $line) {
echo ' ', $line, PHP_EOL;
}2025-03-21 is 1404-1-1
2025-03-20 is 1403-12-30
First day: 1278-10-11
Last day: 1479-10-10
Days checked: 73414
Days that differ: 0All 73,414 days match. The check covers the Shamsi years 1278 to 1479. It says nothing about years outside that range, so I trust the converter only inside it.
Here is the test that compares the two leap rules.
<?php
// The leap rule that ICU (the library behind intl) uses, next to the table of residues in shamsi.php.
// Run with: php leap-rule.php
require __DIR__ . '/shamsi.php';
function icuLeap(int $year): bool
{
return ($year * 25 + 11) % 33 < 8; // from icu4c/source/i18n/persncal.cpp
}
$different = 0;
for ($year = 1; $year <= 1501; $year++) {
if (isShamsiLeap($year) !== icuLeap($year)) {
$different++;
}
}
echo "Years 1 to 1501 where the two rules differ: $different", PHP_EOL;
$leaps = array_filter(range(1400, 1410), 'isShamsiLeap');
echo 'Leap years from 1400 to 1410: ', implode(', ', $leaps), PHP_EOL;Years 1 to 1501 where the two rules differ: 0
Leap years from 1400 to 1410: 1403, 1408Check a date from a user
When a person types a date, you must decide if it exists. checkdate() does this for Gregorian dates only. IntlCalendar is lenient by default, so it fixes a date that does not exist. This program asks for 30 Esfand 1404, which is not a real day. Then it turns on strict mode.
<?php
// By default, IntlCalendar fixes a date that does not exist. In strict mode, it reports an error.
// Run with: php lenient.php
$cal = IntlCalendar::createInstance('UTC', 'en_US@calendar=persian');
$cal->clear();
$cal->set(1404, 11, 30); // 30 Esfand 1404 does not exist (months count from 0)
printf(
"Lenient: %d-%d-%d\n",
$cal->get(IntlCalendar::FIELD_YEAR),
$cal->get(IntlCalendar::FIELD_MONTH) + 1,
$cal->get(IntlCalendar::FIELD_DAY_OF_MONTH)
);
$cal->setLenient(false);
$cal->clear();
$cal->set(1404, 11, 30);
var_dump($cal->getTime());
echo 'Error: ', intl_get_error_message(), PHP_EOL;Lenient: 1405-1-1
bool(false)
Error: intlcal_get_time: error calling ICU Calendar::getTime: U_ILLEGAL_ARGUMENT_ERRORStrict mode works, but the error is a message from ICU. I like to decide the rules myself. The month lengths from my converter give a clear answer.
<?php
// Checks a Shamsi date. DateTime cannot do this, so the rules come from shamsi.php.
// Run with: php validate.php
require __DIR__ . '/shamsi.php';
function isValidShamsiDate(int $year, int $month, int $day): bool
{
if ($year < 1 || $month < 1 || $month > 12 || $day < 1) {
return false;
}
return $day <= daysInShamsiMonth($year, $month);
}
$tests = [
[1403, 12, 30], // 1403 is a leap year
[1404, 12, 30], // 1404 is not
[1404, 6, 31],
[1404, 7, 31],
[1404, 13, 1],
];
foreach ($tests as [$y, $m, $d]) {
echo sprintf('%d-%02d-%02d: ', $y, $m, $d), isValidShamsiDate($y, $m, $d) ? 'valid' : 'not valid', PHP_EOL;
}1403-12-30: valid
1404-12-30: not valid
1404-06-31: valid
1404-07-31: not valid
1404-13-01: not validDay 30 of Esfand is valid in 1403, a leap year, and not valid in 1404. Day 31 is valid in month 6 and not valid in month 7. Those are the mistakes that DateTime would hide.
The time zone changes the day
A date is local. The same moment can fall on two different Shamsi days, depending on the zone. This program converts one moment in two zones.
<?php
// The same moment can fall on two different Shamsi days, depending on the time zone.
// Run with: php time-zone.php
require __DIR__ . '/shamsi.php';
$moment = new DateTimeImmutable('2025-03-20 21:00:00', new DateTimeZone('UTC'));
foreach (['UTC', 'Asia/Tehran'] as $name) {
$local = $moment->setTimezone(new DateTimeZone($name));
[$y, $m, $d] = gregorianToShamsi($local->format('Y-m-d'));
echo str_pad($name, 12), $local->format('Y-m-d H:i'), ' -> Shamsi ', "$y-$m-$d", PHP_EOL;
}UTC 2025-03-20 21:00 -> Shamsi 1403-12-30
Asia/Tehran 2025-03-21 00:30 -> Shamsi 1404-1-1At 21:00 UTC, it is already 00:30 in Tehran, which is the next day. Always choose the zone before you convert. A server that runs in UTC will give the wrong day for people in Iran during the first hours of the local day.
RTLY-Kit: the same work in one package
If you do not want to write this yourself, I made a free library for it. RTLY-Kit is a PHP package for Persian and Arabic applications. It converts Jalali dates, and Hijri and Hebrew dates too. It also checks dates and Iranian national codes, writes numbers as words, and gives holidays and prayer times. It uses the same 33-year rule as this post, so a date like 30 Esfand 1404 is refused. It needs PHP 8.2 or newer and has no required packages. It does not need intl, so the ICU data problem from the second section does not apply. The license is MIT.
Install it with Composer:
composer require enaxon/rtly-kitThen use the helper jdate() and the class Jalali.
<?php
// The same work with RTLY-Kit.
// Install first, in the folder of this file: composer require enaxon/rtly-kit
// Run with: php rtly-kit.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use RtlyKit\Calendar\Jalali;
use RtlyKit\Exceptions\InvalidDateException;
use function RtlyKit\jdate;
echo jdate('2025-03-21')->format('Y/m/d'), PHP_EOL; // Gregorian in, Shamsi out
echo jdate('1404/01/01')->toGregorian()->format('Y-m-d'), PHP_EOL; // Shamsi in, Gregorian out
echo Jalali::create(1403, 12, 30)->format('Y/m/d'), PHP_EOL; // 1403 is a leap year
try {
Jalali::create(1404, 12, 30); // 1404 is not
} catch (InvalidDateException $e) {
echo 'Refused 1404/12/30: ', get_class($e), PHP_EOL;
}1404/01/01
2025-03-21
1403/12/30
Refused 1404/12/30: RtlyKit\Exceptions\InvalidDateExceptionI also ran the check of this post on RTLY-Kit 0.3.0. It converts every day from 1900 to 2100 and compares the result with intl.
<?php
// Checks RTLY-Kit against the intl extension for every day from 1900 to 2100, like check-with-intl.php.
// Install first, in the folder of this file: composer require enaxon/rtly-kit
// Run with: php rtly-kit-check.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use function RtlyKit\jdate;
$tz = new DateTimeZone('UTC');
$cal = IntlCalendar::createInstance($tz, 'en_US@calendar=persian');
$day = new DateTimeImmutable('1900-01-01 12:00:00', $tz);
$end = new DateTimeImmutable('2100-12-31 12:00:00', $tz);
$checked = 0;
$differ = 0;
for (; $day <= $end; $day = $day->modify('+1 day')) {
$cal->setTime($day->getTimestamp() * 1000);
$expected = sprintf(
'%d/%02d/%02d',
$cal->get(IntlCalendar::FIELD_YEAR),
$cal->get(IntlCalendar::FIELD_MONTH) + 1,
$cal->get(IntlCalendar::FIELD_DAY_OF_MONTH)
);
$checked++;
if (jdate($day->format('Y-m-d'), $tz)->format('Y/m/d') !== $expected) {
$differ++;
}
}
echo "Days checked: $checked", PHP_EOL;
echo "Days that differ: $differ", PHP_EOL;Days checked: 73414
Days that differ: 0The page of RTLY-Kit in the apps section has the guides in English, Persian and Arabic. Start with the quick start. The page about the Jalali calendar explains every method of the class. The code is on GitHub (opens in a new tab) and the package is on Packagist (opens in a new tab).
What I use
For output, I use intl when the server has the full ICU data. It is the most complete tool, and it is already in PHP. For input checks, I use the month lengths from my own code, so that I control the error message. I keep my own converter next to a test that compares it with intl, as above. If someone changes the code, the test says so. If you want to avoid both, use the package from the RTLY-Kit section.
I store dates in the database as Gregorian dates or as UTC moments, and I convert only when a person reads or writes a date. That keeps sorting and date math simple.
Read more
- IntlCalendar::createInstance (opens in a new tab) in the PHP manual, with the calendar and time zone arguments
- IntlDateFormatter::create (opens in a new tab) in the PHP manual, where the formatter takes an
IntlCalendar - DateTimeImmutable::createFromFormat (opens in a new tab) in the PHP manual, with the overflow behavior
- persncal.cpp (opens in a new tab) in ICU, the source of the Persian calendar
- RTLY-Kit, the PHP library for Jalali, Hijri and Hebrew dates, with its guides in the apps section
- RTLY-Kit on Packagist (opens in a new tab) and on GitHub (opens in a new tab)
Was this post helpful?