Skip to content

Bug Report: ZoneDateTime Incorrectly Handles DST Transitions with Relative Time Addition #115

@bendavies

Description

@bendavies

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions