Shamsi dates in PHP: where DateTime stops

PHP's DateTime knows only the Gregorian calendar. This post shows what the intl extension can do for Shamsi dates, a small converter that agrees with it on 73,414 days, and a free package that does the work.

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().

gregorian-only.php
<?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;
Output
Read as Gregorian: 1403-12-30
Month 13 rolls over to: 1404-01-05
Warnings: 1

The 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.

intl-persian.php
<?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;
Output
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.1

I ran this on PHP 8.3 with ICU 78.1. Four details are easy to miss:

  • intl counts months from 0, so you must add 1.
  • In the fa_IR locale, the week starts on Saturday. getFirstDayOfWeek() returns 7, because Sunday is 1.
  • To print a Shamsi date, give IntlDateFormatter a calendar object. In my test, the locale string en_US@calendar=persian alone gave a Gregorian date.
  • Names in Persian need the full locale data of ICU. In my test container, fa_IR printed 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.

month-lengths.php
<?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;
}
Output
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 29

Leap 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.

shamsi.php
<?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.

check-with-intl.php
<?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;
}
Output
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: 0

All 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.

leap-rule.php
<?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;
Output
Years 1 to 1501 where the two rules differ: 0
Leap years from 1400 to 1410: 1403, 1408

Check 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.

lenient.php
<?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;
Output
Lenient: 1405-1-1
bool(false)
Error: intlcal_get_time: error calling ICU Calendar::getTime: U_ILLEGAL_ARGUMENT_ERROR

Strict 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.

validate.php
<?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;
}
Output
1403-12-30: valid
1404-12-30: not valid
1404-06-31: valid
1404-07-31: not valid
1404-13-01: not valid

Day 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.

time-zone.php
<?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;
}
Output
UTC         2025-03-20 21:00  ->  Shamsi 1403-12-30
Asia/Tehran 2025-03-21 00:30  ->  Shamsi 1404-1-1

At 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:

Command
composer require enaxon/rtly-kit

Then use the helper jdate() and the class Jalali.

rtly-kit.php
<?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;
}
Output
1404/01/01
2025-03-21
1403/12/30
Refused 1404/12/30: RtlyKit\Exceptions\InvalidDateException

I 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.

rtly-kit-check.php
<?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;
Output
Days checked: 73414
Days that differ: 0

The 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

More posts