PHP date and time: format, time zones and DateTimeImmutable

Getting and formatting a date in PHP: setting the time zone, formatting with date(), calculating with DateTimeImmutable, day and month names in French with IntlDateFormatter, and the deprecated functions to leave behind.

PHP date and time: format, time zones and DateTimeImmutable
Quick answer

date() formats a timestamp, DateTimeImmutable is what you want as soon as a calculation is involved, and IntlDateFormatter gives you day and month names in French. Set the time zone explicitly: without date.timezone, PHP uses UTC. strftime() has been deprecated since PHP 8.1, and its replacement is not date(), which translates nothing.

Printing a date in PHP is easy. Printing the right date, in the right time zone, in French, and working out an interval without tripping over daylight saving, is a lot less so. This article covers the basic functions, then the DateTimeImmutable and IntlDateFormatter classes that handle everything date() cannot. Every output below was produced on PHP 8.5.10.

The time zone comes first

This is the number one source of bugs, and it is a silent one. If date.timezone is not set in php.ini, PHP falls back to UTC. A French site then shows a time that is one or two hours out depending on the season, without the slightest warning.

php
echo ini_get('date.timezone'), "\n";        // (empty) on many installations
echo date_default_timezone_get(), "\n";     // UTC

Set it in php.ini, or at the very top of the application:

php
date_default_timezone_set('Europe/Paris');

The underlying rule: store in UTC, display in the user’s time zone. A database full of local times becomes unusable the moment a user moves time zone or the clocks change.

date() and time()

time() returns the number of seconds elapsed since 1st January 1970 at midnight UTC. date() formats such a timestamp, or the current moment if you do not give it one.

php
date_default_timezone_set('Europe/Paris');

echo date('d/m/Y'), "\n";           // 02/09/2026
echo date('d/m/Y H:i:s'), "\n";     // 02/09/2026 21:47:03
echo time(), "\n";                  // 1788378423

echo date('d/m/Y', 1645303037), "\n";  // 19/02/2022

The format characters you will use most:

Date Time
d day, 2 digits (01 to 31) H hour, 24-hour clock (00 to 23)
j day without a leading zero (1 to 31) h hour, 12-hour clock (01 to 12)
m month, 2 digits (01 to 12) i minutes (00 to 59)
n month without a leading zero (1 to 12) s seconds (00 to 59)
Y year, 4 digits A AM or PM
N day of the week (1 = Monday) P offset, for example +02:00
t number of days in the month U Unix timestamp
L 1 for a leap year c full ISO 8601 format

To output a character that is also a format code, escape it with a backslash:

php
echo date('\L\e d/m/Y'), "\n";      // Le 02/09/2026
// without the backslashes, “L” would give 0 or 1 and “e” the time zone name

date() does not speak French

This is the second classic trap. The day and month names returned by date() are always English, and setlocale() changes nothing.

php
$ts = mktime(0, 0, 0, 4, 1, 2022);

echo date('l', $ts), "\n";                    // Friday
setlocale(LC_TIME, 'fr_FR.UTF-8', 'fr_FR');
echo date('l', $ts), "\n";                    // Friday — unchanged

The answer is IntlDateFormatter, from the intl extension:

php
$formateur = new IntlDateFormatter(
    'fr_FR',
    IntlDateFormatter::FULL,
    IntlDateFormatter::NONE,
    'Europe/Paris',
    IntlDateFormatter::GREGORIAN,
);

echo $formateur->format($ts), "\n";   // vendredi 1 avril 2022

For a custom format, the class takes an ICU pattern, whose codes are not the same as the date() ones:

php
$formateur->setPattern("EEEE d MMMM y '\u{e0}' HH'h'mm");
echo $formateur->format(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')));
// mercredi 2 septembre 2026 à 21h47

If intl is unavailable, a lookup table will get you through for French, but it will not scale to several languages.

DateTimeImmutable, the class to reach for

For anything beyond display, the date classes beat the functions. DateTimeImmutable is the better choice over DateTime: its methods return a new object instead of modifying the current one, which rules out action at a distance.

php
$paris = new DateTimeZone('Europe/Paris');
$date  = new DateTimeImmutable('2026-09-02 14:30:00', $paris);

echo $date->format('d/m/Y H:i P'), "\n";                     // 02/09/2026 14:30 +02:00
echo $date->modify('+3 days')->format('d/m/Y'), "\n";        // 05/09/2026
echo $date->format('d/m/Y'), "\n";                           // 02/09/2026 — unchanged

// With DateTime (mutable), the third line would print 05/09/2026
php
// Adding and subtracting durations
$dans30Mois = $date->add(new DateInterval('P30M'));
$ilYa2Sem   = $date->sub(new DateInterval('P2W'));

// Difference between two dates
$ecart = $date->diff(new DateTimeImmutable('2026-12-25', $paris));
echo $ecart->days, " jours\n";
echo $ecart->format('%m mois et %d jours'), "\n";

// A range of dates
$periode = new DatePeriod(
    $date,
    new DateInterval('P1D'),
    new DateTimeImmutable('2026-09-07', $paris),
);
foreach ($periode as $jour) {
    echo $jour->format('D d/m'), "\n";
}

The end-of-month trap

Adding a month to the end of a month does not give what you expect, and the behaviour is the same with DateInterval as with arithmetic on mktime().

php
$fin = new DateTimeImmutable('2026-01-31');
echo $fin->add(new DateInterval('P1M'))->format('d/m/Y'), "\n";  // 03/03/2026

There is no 31 February, so PHP overflows into March. To get the last day of the following month, ask for it explicitly:

php
echo $fin->modify('last day of next month')->format('d/m/Y'), "\n";  // 28/02/2026

A day is not always 24 hours long

When the clocks go forward a day is 23 hours long, when they go back, 25. Adding 86400 seconds to a timestamp then gives you the wrong time.

php
$paris = new DateTimeZone('Europe/Paris');
$avant = new DateTimeImmutable('2026-03-28 12:00:00', $paris);
$apres = $avant->add(new DateInterval('P1D'));

echo $apres->format('d/m/Y H:i P'), "\n";   // 29/03/2026 12:00 +02:00
echo ($apres->getTimestamp() - $avant->getTimestamp()) / 3600, " heures\n";  // 23

DateInterval thinks in calendar days and correctly lands on midday the next day, even though only 23 hours have passed. That is the right behaviour for a “tomorrow at midday” reminder, and the wrong one for billable time. The two ideas are distinct: P1D for a calendar day, PT24H for twenty-four hours.

Parsing a date entered by the user

strtotime() understands a great deal, but it reads ambiguous formats the American way: 03/04/2026 becomes 4 March, not 3 April. When you know the format, createFromFormat() leaves no room for doubt.

php
$date = DateTimeImmutable::createFromFormat(
    '!d/m/Y',                        // the “!” resets the time to 00:00:00
    '03/04/2026',
    new DateTimeZone('Europe/Paris'),
);
echo $date->format('d F Y'), "\n";   // 03 April 2026

Without the leading !, any field you do not supply is taken from the current moment: two runs of the same code give two different times.

And the most dangerous part: an invalid date does not fail, it silently overflows.

php
$saisie = '31/02/2026';
$date   = DateTimeImmutable::createFromFormat('!d/m/Y', $saisie, $paris);

echo $date->format('d/m/Y'), "\n";                    // 03/03/2026
print_r(DateTimeImmutable::getLastErrors());          // ['warnings' => [10 => 'The parsed date was invalid'], …]
var_dump(checkdate(2, 31, 2026));                     // bool(false)

So check getLastErrors() after every parse, or compare the reformatted date against the original input:

php
function lireDate(string $saisie, DateTimeZone $fuseau): ?DateTimeImmutable
{
    $date = DateTimeImmutable::createFromFormat('!d/m/Y', $saisie, $fuseau);

    if ($date === false) {
        return null;
    }

    $erreurs = DateTimeImmutable::getLastErrors();
    if ($erreurs !== false && ($erreurs['warning_count'] || $erreurs['error_count'])) {
        return null;
    }

    return $date->format('d/m/Y') === $saisie ? $date : null;
}

Since PHP 8.2, getLastErrors() returns false when there is nothing to report, instead of an array of zeroed counters. Your test has to account for that.

Recent additions

PHP 8.4 added createFromTimestamp(), which accepts fractional timestamps:

php
$d = DateTimeImmutable::createFromTimestamp(1645303037.5);
echo $d->format('d/m/Y H:i:s.u P'), "\n";   // 19/02/2022 20:37:17.500000 +00:00

Before that you had to go through '@' . $ts or setTimestamp(), and the microseconds were lost.

Deprecated functions to stop using

Several date functions are still around but emit a deprecation notice. They work on PHP 8.5.10, but their removal is already scheduled.

Function Deprecated since Replacement
strftime() PHP 8.1 IntlDateFormatter::format()
gmstrftime() PHP 8.1 IntlDateFormatter::format()
strptime() PHP 8.2 date_parse_from_format() or IntlDateFormatter::parse()
date_sunrise() / date_sunset() PHP 8.1 date_sun_info()
utf8_encode() PHP 8.2 mb_convert_encoding()

The exact messages, captured on PHP 8.5.10 and on PHP 8.4.25 alike:

code
Deprecated: Function strftime() is deprecated since 8.1,
use IntlDateFormatter::format() instead

Deprecated: Function strptime() is deprecated since 8.2,
use date_parse_from_format() (for locale-independent parsing),
or IntlDateFormatter::parse() (for locale-dependent parsing) instead

Deprecated: Constant SUNFUNCS_RET_STRING is deprecated since 8.4,
as date_sunrise() and date_sunset() were deprecated in 8.1

strftime() was the usual way of getting a date in French. Its replacement is not date(), which translates nothing, but IntlDateFormatter.

Conversely, mktime(), checkdate(), getdate() and idate() are not deprecated and work as usual. They are still handy for simple cases, even though the classes cover everything they do.

Key points

  • Set the time zone explicitly, store in UTC, display in local time.
  • date() to format a moment, DateTimeImmutable for any calculation.
  • IntlDateFormatter for day and month names in French.
  • After createFromFormat(), check getLastErrors(): an invalid date overflows in silence.
  • A calendar day is not 24 hours. P1D and PT24H are not interchangeable.

To parse dates by the million in an import file, see reading large files with PHP, and connecting to a database to store them in UTC.

See also validating a date with a regular expression, handling dates with Carbon in Laravel and the web development hub.

Common errors

Time zone left unset Without date.timezone in php.ini, PHP falls back to UTC and shows a shifted time without the slightest warning.
date('l') is always English setlocale() has no effect on date(). The replacement for strftime() is IntlDateFormatter, not date().
createFromFormat accepts an invalid date 31/02/2026 becomes 03/03/2026, with nothing but a warning in getLastErrors(). You have to check after every parse.
createFromFormat without the “!” Any field you do not supply takes the current time: two runs of the same code give two results.
strtotime reads dates the American way 03/04/2026 becomes 4 March, not 3 April.
Adding a month to the end of a month 31/01/2026 + P1M gives 03/03/2026. For the last day of the month, use modify('last day of next month').
A day is not 24 hours When the clocks change, P1D does move forward one calendar day even though only 23 hours have passed. P1D and PT24H are not interchangeable.
getLastErrors returns false Since PHP 8.2, the method returns false when there is nothing to report, rather than an array of zeroed counters.

DateDateTimeIntlPHP

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.