
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.
echo ini_get('date.timezone'), "\n"; // (empty) on many installations
echo date_default_timezone_get(), "\n"; // UTCSet it in php.ini, or at the very top of the application:
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.
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/2022The 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:
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 namedate() 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.
$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 — unchangedThe answer is IntlDateFormatter, from the intl extension:
$formateur = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
'Europe/Paris',
IntlDateFormatter::GREGORIAN,
);
echo $formateur->format($ts), "\n"; // vendredi 1 avril 2022For a custom format, the class takes an ICU pattern, whose codes are not the same as the date() ones:
$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 à 21h47If 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.
$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// 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().
$fin = new DateTimeImmutable('2026-01-31');
echo $fin->add(new DateInterval('P1M'))->format('d/m/Y'), "\n"; // 03/03/2026There is no 31 February, so PHP overflows into March. To get the last day of the following month, ask for it explicitly:
echo $fin->modify('last day of next month')->format('d/m/Y'), "\n"; // 28/02/2026A 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.
$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"; // 23DateInterval 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.
$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 2026Without 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.
$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:
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:
$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:00Before 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:
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.1strftime() 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,DateTimeImmutablefor any calculation.IntlDateFormatterfor day and month names in French.- After
createFromFormat(), checkgetLastErrors(): an invalid date overflows in silence. - A calendar day is not 24 hours.
P1DandPT24Hare 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
date.timezone in php.ini, PHP falls back to UTC and shows a shifted time without the slightest warning.setlocale() has no effect on date(). The replacement for strftime() is IntlDateFormatter, not date().31/02/2026 becomes 03/03/2026, with nothing but a warning in getLastErrors(). You have to check after every parse.03/04/2026 becomes 4 March, not 3 April.31/01/2026 + P1M gives 03/03/2026. For the last day of the month, use modify('last day of next month').P1D does move forward one calendar day even though only 23 hours have passed. P1D and PT24H are not interchangeable.false when there is nothing to report, rather than an array of zeroed counters.

