Bug Report: ZoneDateTime Incorrectly Handles DST Transitions with Relative Time Addition
Hi,
This bug post is written off the back of: https://phpfashion.com/en/100-minutes-is-less-than-50-php-paradoxes-during-time-changes
Description
When adding relative time (e.g., '+100 minutes') to a ZonedDateTime object during a period that crosses a Daylight Saving Time (DST) transition, PHP and brick/date-time incorrectly(?) calculates the resulting time.
Specifically, when the base time is before a "spring forward" DST transition, adding a larger amount of time sometimes results in an earlier time than adding a smaller amount.
I checked the behaviour against PostgreSQL and Java, both of which handle the same scenario correctly(?).
I am therefore asserting going forward that php and brick/date-time are incorrect, and java and PostgreSQL are correct.
PHP Code with Incorrect Behavior
<?php
date_default_timezone_set('Europe/Prague');
$now = new DateTimeImmutable('2025-03-30 01:30:00');
echo 'Original: '. $now->format('Y-m-d H:i:s T (P)') . PHP_EOL;
$dt50 = $now->modify('+50 minutes');
echo 'Plus 50 minutes: '. $dt50->format('Y-m-d H:i:s T (P)').PHP_EOL;
$dt100 = $now->modify('+100 minutes');
echo 'Plus 100 minutes: '. $dt100->format('Y-m-d H:i:s T (P)').PHP_EOL;
// Original: 2025-03-30 01:30:00 CET (+01:00)
// Plus 50 minutes: 2025-03-30 03:20:00 CEST (+02:00)
// Plus 100 minutes: 2025-03-30 03:10:00 CEST (+02:00)
brick/date-time Code with Incorrect Behavior
<?php
declare(strict_types=1);
use Brick\DateTime\ZonedDateTime;
require_once __DIR__.'/vendor/autoload.php';
date_default_timezone_set('Europe/Prague');
$now = ZonedDateTime::fromNativeDateTime(new DateTimeImmutable('2025-03-30 01:30:00'));
echo 'Original: '. $now->toNativeDateTimeImmutable()->format('Y-m-d H:i:s T (P)') . PHP_EOL;
$dt50 = $now->plusMinutes(50);
echo 'Plus 50 minutes: '. $dt50->toNativeDateTimeImmutable()->format('Y-m-d H:i:s T (P)').PHP_EOL;
$dt100 = $now->plusMinutes(100);
echo 'Plus 100 minutes: '. $dt100->toNativeDateTimeImmutable()->format('Y-m-d H:i:s T (P)').PHP_EOL;
// Original: 2025-03-30 01:30:00 CET (+01:00)
// Plus 50 minutes: 2025-03-30 03:20:00 CEST (+02:00)
// Plus 100 minutes: 2025-03-30 03:10:00 CEST (+02:00)
Correct behavior in PostgreSQL
SET timezone = 'Europe/Prague';
SELECT
'2025-03-30 01:30:00'::timestamptz AS original_time,
'2025-03-30 01:30:00'::timestamptz + INTERVAL '50 minutes' AS time_plus_50_min,
'2025-03-30 01:30:00'::timestamptz + INTERVAL '100 minutes' AS time_plus_100_min;
-- Output:
-- | original_time | time_plus_50_min | time_plus_100_min |
-- |------------------------|------------------------|------------------------|
-- | 2025-03-30 01:30:00+01 | 2025-03-30 03:20:00+02 | 2025-03-30 04:10:00+02 |
Correct behavior in Java
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
public class DSTExample {
public static void main(String[] args) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z (O)");
ZoneId zoneId = ZoneId.of("Europe/Prague");
ZonedDateTime baseTime = ZonedDateTime.of(2025, 3, 30, 1, 30, 0, 0, zoneId);
System.out.println("Original time: " + baseTime.format(formatter));
ZonedDateTime plusFiftyMinutes = baseTime.plus(50, ChronoUnit.MINUTES);
System.out.println("Plus 50 minutes: " + plusFiftyMinutes.format(formatter));
ZonedDateTime plusHundredMinutes = baseTime.plus(100, ChronoUnit.MINUTES);
System.out.println("Plus 100 minutes: " + plusHundredMinutes.format(formatter));
}
}
// Output:
// Original time: 2025-03-30 01:30:00 CET (GMT+1)
// Plus 50 minutes: 2025-03-30 03:20:00 CEST (GMT+2)
// Plus 100 minutes: 2025-03-30 04:10:00 CEST (GMT+2)
Expected vs Actual Results
Expected (as shown by PostgreSQL and Java):
- Original time: 2025-03-30 01:30:00 CET
- +50 minutes: 2025-03-30 03:20:00 CEST
- +100 minutes: 2025-03-30 04:10:00 CEST
Actual in PHP and Brick
- Original time: 2025-03-30 01:30:00 CET
- +50 minutes: 2025-03-30 03:20:00 CEST
- +100 minutes: 2025-03-30 03:10:00 CEST (incorrect - should be 04:10:00)
Analysis
The Europe/Prague DST transition is at 2am.
This appears to be an error in PHP's DateTime handling. When adding 100 minutes (1 hour 40 minutes) to 01:30, the correct result should be:
- 01:30 + 1h40m = 04:10
- because at 02:00 a.m. clocks turned to 3:00 a.m.
However, PHP incorrectly computes this as 03:10, suggesting it's not properly accounting for the DST transition.
PHP does correctly handle the +50 minute transition, because it lands at 02:20:00, adding a further 60 minutes for the DST transition, resulting in 03:20:00.
if anyone is wondering, aeon-php/calendar handles this correctly
<?php
use Aeon\Calendar\Gregorian\DateTime;
date_default_timezone_set('Europe/Prague');
$now = new DateTimeImmutable('2025-03-30 01:30:00');
$aeon = DateTime::fromDateTime($now);
echo 'Original: '. $aeon->format('Y-m-d H:i:s T (P)') . PHP_EOL;
$dt50 = $aeon->addMinutes(50);
echo 'Plus 50 minutes: '. $dt50->format('Y-m-d H:i:s T (P)').PHP_EOL;
$dt100 = $aeon->addMinutes(100);
echo 'Plus 100 minutes: '. $dt100->format('Y-m-d H:i:s T (P)').PHP_EOL;
# output
# Original: 2025-03-30 01:30:00 CET (+01:00)
# Plus 50 minutes: 2025-03-30 03:20:00 CEST (+02:00)
# Plus 100 minutes: 2025-03-30 04:10:00 CEST (+02:00)
futhermore, azjezz/psl handles this "correctly"
use Psl\DateTime\DateTime;
use Psl\DateTime\FormatPattern;
use Psl\DateTime\Timezone;
$x = DateTime::parse('2025-03-30 01:30:00', FormatPattern::SqlDateTime, timezone: Timezone::EuropePrague);
$a = $x->plusMinutes(50);
$b = $x->plusMinutes(100);
var_dump(
$x->format(FormatPattern::SqlDateTime),
$a->format(FormatPattern::SqlDateTime),
$b->format(FormatPattern::SqlDateTime),
);
# string(19) "2025-03-30 01:30:00"
# string(19) "2025-03-30 03:20:00"
# string(19) "2025-03-30 04:10:00"
Thank you for the bug report, @bendavies!
I fixed this in d8bbfbc44f13bb0f03dcb3986036f212f864ff8c.
The issue was that we added minutes to the underlying local date, then converted again to ZonedDateTime. It worked fine for +50 as the resulting ZonedDateTime was invalid, and the clock was moved forward one hour to fix it; but didn't work for +100, as the result was a valid (yet incorrect) date-time for the timezone.
I added the delta to the underlying Instant instead, and it now passes your tests.
I fixed the following methods:
plusSeconds()plusMinutes()plusHours()plusDays()plusWeeks()
as well as their minus*() counterparts.
All of these resolve to a number of seconds, so can be added to an Instant.
I had to leave out plusMonths() and plusYears(), which do not resolve to a number of seconds; I guess this is fine (?)
Now a question remains: what to do with plusPeriod()? A Period consists of a number of years, months, and days. If we apply the same logic, that means that we'd use a given calculation method for the years/months part, and another one for the days. Or we can just let it behave the way it does currently, but then it becomes somewhat inconsistent with plusDays().
I'll have to check what Java does here, it will greatly help decide what to do.
In Java, plusDays(1) actually behaves differently from plusHours(24):
import java.time.*;
import java.time.format.DateTimeFormatter;
public class Main {
public static void main(String[] args) {
var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z (O)");
var a = ZonedDateTime.parse("2025-03-29T02:30+01:00[Europe/Prague]");
var b = ZonedDateTime.parse("2025-03-29T03:30+01:00[Europe/Prague]");
System.out.println("a = " + a.format(formatter));
System.out.println("b = " + b.format(formatter));
System.out.println();
System.out.println("a + 24 hours = " + a.plusHours(24).format(formatter));
System.out.println("a + 1 day = " + a.plusDays(1).format(formatter));
System.out.println();
System.out.println("b + 24 hours = " + b.plusHours(24).format(formatter));
System.out.println("b + 1 day = " + b.plusDays(1).format(formatter));
}
}
a = 2025-03-29 02:30:00 CET (GMT+1)
b = 2025-03-29 03:30:00 CET (GMT+1)
a + 24 hours = 2025-03-30 03:30:00 CEST (GMT+2)
a + 1 day = 2025-03-30 03:30:00 CEST (GMT+2)
b + 24 hours = 2025-03-30 04:30:00 CEST (GMT+2)
b + 1 day = 2025-03-30 03:30:00 CEST (GMT+2)
So when adding days, Java adds the period to the local date-time, then resolves to the next hour if the resulting date-time falls inside a DST transition (which is the current Brick behaviour). Java adds to the Instant only when adding hours, minutes or seconds; which answers my question about Periods too.
In summary: I'll fix plusHours(), plusMinutes() and plusSeconds(), and leave the current behaviour untouched for plusDays(), plusWeeks and plusPeriod(). I'll also add tests to ensure that each of these methods behaves like Java does during DST transitions.
This bug has been fixed in d8bbfbc44f13bb0f03dcb3986036f212f864ff8c and 0ac3e787f7df357f5cb42c2a2671e5e0ae5c6978. The fix was released in version 0.7.1.
Thank you for reporting this bug!