Last Sunday the European Union switched from CET (+0100) to CEST (+0200). I'm writing code to apply an increment to a date and it isn't working properly because time zone transition is only properly taken into account with some relative formats:
'+x minutes'
omits the missing hour'+x hours'
doesn'tHere's my test code:
echo 'Time zone database: ' . timezone_version_get() . PHP_EOL;
echo PHP_EOL;
date_default_timezone_set('Europe/Madrid');
$start = new DateTime('2017-03-26 01:59:00');
$increments = array(
'+2 minutes' => '2017-03-26 03:01:00',
'+2 hours' => '2017-03-26 04:59:00',
);
echo 'Start: ' . $start->format('r') . PHP_EOL;
foreach ($increments as $increment => $expected_string) {
echo '>>> ' . $increment . PHP_EOL;
$expected_end = new DateTime($expected_string);
$actual_end = clone $start;
$actual_end->modify($increment);
echo 'Expected end: ' . $expected_end->format('r') . PHP_EOL;
echo 'Actual end: ' . $actual_end->format('r') . PHP_EOL;
echo ($expected_end->format('c')===$actual_end->format('c') ? 'OK' : 'ERROR') . PHP_EOL;
echo PHP_EOL;
}
Time zone database: 2016.3
Start: Sun, 26 Mar 2017 01:59:00 +0100
>>> +2 minutes
Expected end: Sun, 26 Mar 2017 03:01:00 +0200
Actual end: Sun, 26 Mar 2017 03:01:00 +0200
OK
>>> +2 hours
Expected end: Sun, 26 Mar 2017 04:59:00 +0200
Actual end: Sun, 26 Mar 2017 03:59:00 +0200
ERROR
Since relative formats are often so counter-intuitive I'm not sure whether I'm getting some documented behaviour or it's a bug.
Can you shed some light on it?
It can't be a relative format misunderstanding because behaviour is erratic within the same format:
date_default_timezone_set('Europe/Madrid');
$start = new DateTime('2017-03-26 01:59:00');
$increments = array(
'+60 minutes' => '2017-03-26 03:59:00',
'+61 minutes' => '2017-03-26 04:00:00',
);
echo 'Start: ' . $start->format('r') . PHP_EOL;
foreach ($increments as $increment => $expected_string) {
echo '>>> ' . $increment . PHP_EOL;
$expected_end = new DateTime($expected_string);
$actual_end = clone $start;
$actual_end->modify($increment);
echo 'Expected end: ' . $expected_end->format('r') . PHP_EOL;
echo 'Actual end: ' . $actual_end->format('r') . PHP_EOL;
echo ($expected_end->format('c')===$actual_end->format('c') ? 'OK' : 'ERROR') . PHP_EOL;
echo PHP_EOL;
}
Start: Sun, 26 Mar 2017 01:59:00 +0100
>>> +60 minutes
Expected end: Sun, 26 Mar 2017 03:59:00 +0200
Actual end: Sun, 26 Mar 2017 03:59:00 +0200
OK
>>> +61 minutes
Expected end: Sun, 26 Mar 2017 04:00:00 +0200
Actual end: Sun, 26 Mar 2017 03:00:00 +0200
ERROR
In other words, adding 61 minutes produces an earlier date than adding 60.
In short, PHP does not handle time zone transitions properly. There's an issue ticket that acknowledges it and even an RFC from 2011 that analyses possible fixes.
(Credit goes to @Alex Blex for this information.)
It's worth noting that good old Unix-timestamp based functions are affected too:
<?php
date_default_timezone_set('Europe/Madrid');
$start = strtotime('2017-03-26 01:59:00');
$increments = array(
'+60 minutes' => '2017-03-26 03:59:00',
'+61 minutes' => '2017-03-26 04:00:00',
);
echo 'Start: ' . date('r', $start) . PHP_EOL;
foreach ($increments as $increment => $expected_string) {
echo '>>> ' . $increment . PHP_EOL;
$expected_end = strtotime($expected_string);
$actual_end = strtotime($increment, $start);
echo 'Expected end: ' . date('r', $expected_end) . PHP_EOL;
echo 'Actual end: ' . date('r', $actual_end) . PHP_EOL;
echo ($expected_end===$actual_end ? 'OK' : 'ERROR') . PHP_EOL;
echo PHP_EOL;
}
Start: Sun, 26 Mar 2017 01:59:00 +0100
>>> +60 minutes
Expected end: Sun, 26 Mar 2017 03:59:00 +0200
Actual end: Sun, 26 Mar 2017 03:59:00 +0200
OK
>>> +61 minutes
Expected end: Sun, 26 Mar 2017 04:00:00 +0200
Actual end: Sun, 26 Mar 2017 03:00:00 +0200
ERROR
Use UTC, of course :)
You can either use UTC internally for all calculations or switch to UTC before performing date maths. The latter (the most verbose case) implies something like:
<?php
date_default_timezone_set('Europe/Madrid');
$start = new DateTime('2017-03-26 01:59:00');
$increments = array(
'+60 minutes' => '2017-03-26 03:59:00',
'+61 minutes' => '2017-03-26 04:00:00',
);
echo 'Start: ' . $start->format('r') . PHP_EOL;
$local = $start->getTimezone();
$utc = new DateTimeZone('UTC');
foreach ($increments as $increment => $expected_string) {
echo '>>> ' . $increment . PHP_EOL;
$expected_end = new DateTime($expected_string);
$actual_end = clone $start;
$actual_end->setTimezone($utc);
$actual_end->modify($increment);
$actual_end->setTimezone($local);
echo 'Expected end: ' . $expected_end->format('r') . PHP_EOL;
echo 'Actual end: ' . $actual_end->format('r') . PHP_EOL;
echo ($expected_end->format('c')===$actual_end->format('c') ? 'OK' : 'ERROR') . PHP_EOL;
echo PHP_EOL;
}
Start: Sun, 26 Mar 2017 01:59:00 +0100
>>> +60 minutes
Expected end: Sun, 26 Mar 2017 03:59:00 +0200
Actual end: Sun, 26 Mar 2017 03:59:00 +0200
OK
>>> +61 minutes
Expected end: Sun, 26 Mar 2017 04:00:00 +0200
Actual end: Sun, 26 Mar 2017 04:00:00 +0200
OK
If you use UTC everywhere you don't need any of this, just a final ->setTimezone()
when displaying to end user.