From 0c2630a45d286dd198f5f0f03fcfcd801502a4ad Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Sat, 10 Oct 2026 10:23:15 +0200 Subject: [PATCH 1/2] Use the parsed offset in ZonedDateTime::parse() when a region is present from() discarded the parsed offset and resolved the local date-time against the region again. In a DST overlap, the first occurrence therefore parsed as the second one, so parse((string) $zdt) did not round-trip, and an offset that the region does not use at that time was silently replaced: ZonedDateTime::parse('2024-10-27T02:30+02:00[Europe/Zurich]'); // +01:00, one hour later ZonedDateTime::parse('2024-06-01T12:00Z[Europe/Zurich]'); // 12:00+02:00, another instant The instant is now computed from the local date-time and the offset, and a DateTimeException is thrown when that offset is not valid for the region. A test fixture used -07:00 for America/Los_Angeles in February, an offset that only passed because it was ignored; it now uses -08:00. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fm9bvgKFasEGzpK48bQrxA --- src/ZonedDateTime.php | 24 +++++++++++++++++------- tests/ZonedDateTimeTest.php | 25 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index 92eebb10..8726a475 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -19,6 +19,7 @@ use function assert; use function intdiv; use function rtrim; +use function sprintf; use function str_pad; use const STR_PAD_LEFT; @@ -165,15 +166,24 @@ public static function from(DateTimeParseResult $result): ZonedDateTime $timeZoneOffset = TimeZoneOffset::from($result); if ($result->hasField(Field\TimeZoneRegion::NAME)) { - $timeZone = TimeZoneRegion::from($result); - } else { - $timeZone = $timeZoneOffset; + $zonedDateTime = ZonedDateTime::ofInstant( + ZonedDateTime::of($localDateTime, $timeZoneOffset)->getInstant(), + TimeZoneRegion::from($result), + ); + + if (! $zonedDateTime->getTimeZoneOffset()->isEqualTo($timeZoneOffset)) { + throw new DateTimeException(sprintf( + 'The offset %s is not valid for %s in %s.', + $timeZoneOffset, + $localDateTime, + $zonedDateTime->getTimeZone(), + )); + } + + return $zonedDateTime; } - return ZonedDateTime::of( - $localDateTime, - $timeZone, - ); + return ZonedDateTime::of($localDateTime, $timeZoneOffset); } /** diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php index e88b7cb0..03c2af6e 100644 --- a/tests/ZonedDateTimeTest.php +++ b/tests/ZonedDateTimeTest.php @@ -482,6 +482,9 @@ public static function providerParse(): iterable ['2001-02-03T01:02+00:00[Europe/London]', '2001-02-03', '01:02', 'Z', 'Europe/London'], ['2001-02-03T01:02:03-00:00[Europe/London]', '2001-02-03', '01:02:03', 'Z', 'Europe/London'], ['2001-02-03T01:02:03.456+00:00[Europe/London]', '2001-02-03', '01:02:03.456', 'Z', 'Europe/London'], + ['2001-07-03T01:02+01:00[Europe/London]', '2001-07-03', '01:02', '+01:00', 'Europe/London'], + ['2024-10-27T02:30+02:00[Europe/Zurich]', '2024-10-27', '02:30', '+02:00', 'Europe/Zurich'], + ['2024-10-27T02:30+01:00[Europe/Zurich]', '2024-10-27', '02:30', '+01:00', 'Europe/Zurich'], ]; if (PHP_VERSION_ID >= 80107) { @@ -489,6 +492,26 @@ public static function providerParse(): iterable } } + #[DataProvider('providerParseWithOffsetNotValidForRegionThrowsException')] + public function testParseWithOffsetNotValidForRegionThrowsException(string $text, string $expectedMessage): void + { + $this->expectException(DateTimeException::class); + $this->expectExceptionMessage($expectedMessage); + + ZonedDateTime::parse($text); + } + + public static function providerParseWithOffsetNotValidForRegionThrowsException(): array + { + return [ + ['2001-02-03T01:02+01:00[Europe/London]', 'The offset +01:00 is not valid for 2001-02-03T01:02 in Europe/London.'], + ['2024-06-01T12:00Z[Europe/Zurich]', 'The offset Z is not valid for 2024-06-01T12:00 in Europe/Zurich.'], + ['2024-06-01T12:00+05:00[Europe/Zurich]', 'The offset +05:00 is not valid for 2024-06-01T12:00 in Europe/Zurich.'], + ['2024-03-31T02:30+01:00[Europe/Zurich]', 'The offset +01:00 is not valid for 2024-03-31T02:30 in Europe/Zurich.'], + ['2024-03-31T02:30+02:00[Europe/Zurich]', 'The offset +02:00 is not valid for 2024-03-31T02:30 in Europe/Zurich.'], + ]; + } + #[DataProvider('providerParseInvalidStringThrowsException')] public function testParseInvalidStringThrowsException(string $text): void { @@ -758,7 +781,7 @@ public static function providerPlusPeriod(): Generator ['2000-02-16T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofMonths(2), '2000-04-16T12:34:56.123456789-07:00[America/Los_Angeles]'], ['2000-05-16T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofMonths(-1), '2000-04-16T12:34:56.123456789-07:00[America/Los_Angeles]'], ['2000-05-16T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofMonths(-2), '2000-03-16T12:34:56.123456789-08:00[America/Los_Angeles]'], - ['2000-02-16T12:34:56.123456789-07:00[America/Los_Angeles]', Period::of(1, -2, 3), '2000-12-19T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-02-16T12:34:56.123456789-08:00[America/Los_Angeles]', Period::of(1, -2, 3), '2000-12-19T12:34:56.123456789-08:00[America/Los_Angeles]'], ]; foreach (self::providerPlusDays() as [$zonedDateTime, $plusDays, $expected]) { From 84e568d1fd931335179f8418a14f60db764252b9 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Sat, 10 Oct 2026 13:37:58 +0200 Subject: [PATCH 2/2] Report a local date-time in a DST gap distinctly in ZonedDateTime::parse() Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fm9bvgKFasEGzpK48bQrxA --- src/ZonedDateTime.php | 15 +++++++++++++-- tests/ZonedDateTimeTest.php | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index 8726a475..28f2a822 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -166,17 +166,28 @@ public static function from(DateTimeParseResult $result): ZonedDateTime $timeZoneOffset = TimeZoneOffset::from($result); if ($result->hasField(Field\TimeZoneRegion::NAME)) { + $timeZone = TimeZoneRegion::from($result); + $zonedDateTime = ZonedDateTime::ofInstant( ZonedDateTime::of($localDateTime, $timeZoneOffset)->getInstant(), - TimeZoneRegion::from($result), + $timeZone, ); if (! $zonedDateTime->getTimeZoneOffset()->isEqualTo($timeZoneOffset)) { + // In a gap, of() shifts the local date-time forward. + if (! ZonedDateTime::of($localDateTime, $timeZone)->getDateTime()->isEqualTo($localDateTime)) { + throw new DateTimeException(sprintf( + 'The local date-time %s does not exist in %s.', + $localDateTime, + $timeZone, + )); + } + throw new DateTimeException(sprintf( 'The offset %s is not valid for %s in %s.', $timeZoneOffset, $localDateTime, - $zonedDateTime->getTimeZone(), + $timeZone, )); } diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php index 03c2af6e..2090a222 100644 --- a/tests/ZonedDateTimeTest.php +++ b/tests/ZonedDateTimeTest.php @@ -507,8 +507,8 @@ public static function providerParseWithOffsetNotValidForRegionThrowsException() ['2001-02-03T01:02+01:00[Europe/London]', 'The offset +01:00 is not valid for 2001-02-03T01:02 in Europe/London.'], ['2024-06-01T12:00Z[Europe/Zurich]', 'The offset Z is not valid for 2024-06-01T12:00 in Europe/Zurich.'], ['2024-06-01T12:00+05:00[Europe/Zurich]', 'The offset +05:00 is not valid for 2024-06-01T12:00 in Europe/Zurich.'], - ['2024-03-31T02:30+01:00[Europe/Zurich]', 'The offset +01:00 is not valid for 2024-03-31T02:30 in Europe/Zurich.'], - ['2024-03-31T02:30+02:00[Europe/Zurich]', 'The offset +02:00 is not valid for 2024-03-31T02:30 in Europe/Zurich.'], + ['2024-03-31T02:30+01:00[Europe/Zurich]', 'The local date-time 2024-03-31T02:30 does not exist in Europe/Zurich.'], + ['2024-03-31T02:30+02:00[Europe/Zurich]', 'The local date-time 2024-03-31T02:30 does not exist in Europe/Zurich.'], ]; }