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;
}
(run online)
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;
}
(run online)
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
Workaround
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;
}
(run online)
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.