From f65a63d95805cd8a042361475e3210ded69da028 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Tue, 6 Oct 2026 22:55:59 +0200 Subject: [PATCH 1/6] Speed up ZonedDateTime::of(), and support years beyond 9999 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe --- src/ZonedDateTime.php | 37 +++++++++++++++++-------- tests/LocalDateTimeTest.php | 4 +++ tests/ZonedDateTimeTest.php | 54 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index ef25bf77..fca1fa4f 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -76,21 +76,36 @@ private function __construct( */ public static function of(LocalDateTime $dateTime, TimeZone $timeZone): ZonedDateTime { - $dtz = $timeZone->toNativeDateTimeZone(); - $dt = new DateTime((string) $dateTime->withNano(0), $dtz); - - $instant = Instant::of($dt->getTimestamp(), $dateTime->getNano()); + $localEpochSecond = $dateTime->getDate()->toEpochDay() * LocalTime::SECONDS_PER_DAY + + $dateTime->getTime()->toSecondOfDay(); if ($timeZone instanceof TimeZoneOffset) { - $timeZoneOffset = $timeZone; - } else { - $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset()); + // A fixed offset has no DST transition: every local date-time is valid, and maps to a single instant. + $instant = Instant::of($localEpochSecond - $timeZone->getTotalSeconds(), $dateTime->getNano()); + + return new ZonedDateTime($dateTime, $timeZone, $timeZone, $instant); + } + + $text = (string) $dateTime->withNano(0); + + // DateTime requires an explicit sign for years beyond 9999. + if ($dateTime->getYear() > 9999) { + $text = '+' . $text; + } + + $dt = new DateTime($text, $timeZone->toNativeDateTimeZone()); + + $epochSecond = $dt->getTimestamp(); + $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset()); + + // The time is shifted forward if the date-time falls in a DST gap. + $shift = $epochSecond + $timeZoneOffset->getTotalSeconds() - $localEpochSecond; + + if ($shift !== 0) { + $dateTime = $dateTime->plusSeconds($shift); } - // The time can be affected if the date-time is not valid for the given time-zone due to a DST transition, - // so we have to re-compute the local date-time from the DateTime object. - // DateTime does not support nanos of seconds, so we just copy the nanos back from the original date-time. - $dateTime = LocalDateTime::parse($dt->format('Y-m-d\TH:i:s'))->withNano($dateTime->getNano()); + $instant = Instant::of($epochSecond, $dateTime->getNano()); return new ZonedDateTime($dateTime, $timeZoneOffset, $timeZone, $instant); } diff --git a/tests/LocalDateTimeTest.php b/tests/LocalDateTimeTest.php index defad826..867ec9a1 100644 --- a/tests/LocalDateTimeTest.php +++ b/tests/LocalDateTimeTest.php @@ -1062,6 +1062,10 @@ public static function providerAtTimeZone(): array ['1960-04-30T06:00:00.123456', '+02:00', -305236800, 123456000], ['2008-01-02T12:34:56', 'Europe/Paris', 1199273696, 0], ['2008-01-02T12:34:56.123', 'America/Los_Angeles', 1199306096, 123000000], + ['10000-01-01T00:00', 'Z', 253402300800, 0], + ['999999-12-31T23:59:59.999999999', 'Z', 31494784780799, 999999999], + ['-999999-01-01T00:00', 'Z', -31619087596800, 0], + ['10000-07-01T12:00:00.123', 'Europe/Paris', 253418061600, 123000000], ]; } diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php index d312d779..6e95845a 100644 --- a/tests/ZonedDateTimeTest.php +++ b/tests/ZonedDateTimeTest.php @@ -100,6 +100,20 @@ public static function providerOf(): array ['2011-06-30T12:34:56.123456789', '+17:00', '+17:00', 0, 1309376096, 123456789], ['2011-07-31T12:34:56.123456789', '+18:00', '+18:00', 0, 1312050896, 123456789], + // WITH OFFSET FROM UTC, EDGE CASES + + ['2020-01-01T00:00:00.000000000', '+05:30:45', '+05:30:45', 0, 1577816955, 0], + ['0000-01-01T00:00:00.000000000', '+00:00', '+00:00', 0, -62167219200, 0], + ['9999-12-31T23:59:59.999999999', '+18:00', '+18:00', 0, 253402235999, 999999999], + ['10000-02-29T12:00:00.000000000', '-01:00', '-01:00', 0, 253407445200, 0], + ['-999999-01-01T00:00:00.000000000', '-18:00', '-18:00', 0, -31619087532000, 0], + ['-999999-01-01T00:00:00.000000000', '+00:00', '+00:00', 0, -31619087596800, 0], + ['-0001-12-31T23:59:59.999999999', '+01:00', '+01:00', 0, -62167222801, 999999999], + ['10000-01-01T00:00:00.000000000', '+00:00', '+00:00', 0, 253402300800, 0], + ['10000-01-01T00:00:00.000000000', '-18:00', '-18:00', 0, 253402365600, 0], + ['999999-12-31T23:59:59.999999999', '+00:00', '+00:00', 0, 31494784780799, 999999999], + ['999999-12-31T23:59:59.999999999', '+18:00', '+18:00', 0, 31494784715999, 999999999], + // WITH REGION, NORMAL: NOT WITHIN A DST TRANSITION // The region is resolved to an offset without ambiguity. // The date-time is resolved to an instant without ambiguity. @@ -326,9 +340,49 @@ public static function providerOf(): array ['2014-10-05T02:59:59.999999999', 'Australia/Sydney', '+11:00', 3600, 1412441999, 999999999], ['2014-10-05T03:00:00.000000000', 'Australia/Sydney', '+11:00', 0, 1412438400, 0], ['2014-10-05T03:59:59.999999999', 'Australia/Sydney', '+11:00', 0, 1412441999, 999999999], + + // WITH REGION, EDGE CASES + + // Pacific/Apia: at midnight (-10:00) on 2011-12-30, clocks jumped forward 24 hours (+14:00), skipping that day + ['2011-12-29T23:59:59.999999999', 'Pacific/Apia', '-10:00', 0, 1325239199, 999999999], + ['2011-12-30T12:00:00.000000000', 'Pacific/Apia', '+14:00', 86400, 1325282400, 0], + // Australia/Lord_Howe: 30-minute DST, gap from 2:00 AM to 2:30 AM, overlap from 1:30 AM to 2:00 AM + ['2013-10-06T02:15:00.000000000', 'Australia/Lord_Howe', '+11:00', 1800, 1380987900, 0], + ['2014-04-06T01:45:00.000000000', 'Australia/Lord_Howe', '+10:30', 0, 1396710900, 0], + // America/Sao_Paulo: gap at midnight, from 0:00 AM to 1:00 AM + ['2008-10-19T00:30:00.000000000', 'America/Sao_Paulo', '-02:00', 3600, 1224387000, 0], + // Local mean time, with an offset in seconds + ['1900-01-01T00:00:00.000000000', 'Europe/Paris', '+00:09:21', 0, -2208989361, 0], + ['-0001-12-31T23:59:59.999999999', 'America/New_York', '-04:56:02', 0, -62167201439, 999999999], + ['-999999-01-01T00:00:00.000000000', 'Europe/Paris', '+00:09:21', 0, -31619087597361, 0], + // Years 9999 to 10000, and up to the max + ['9999-12-31T23:59:59.999999999', 'Europe/Paris', '+01:00', 0, 253402297199, 999999999], + ['10000-01-01T00:00:00.000000000', 'Europe/Paris', '+01:00', 0, 253402297200, 0], + ['999999-12-31T23:59:59.999999999', 'Europe/Paris', '+01:00', 0, 31494784777199, 999999999], + + // Europe/Paris beyond year 9999: DST transitions on the last Sunday of March and October at 01:00 UTC. + + ['10000-07-01T12:00:00.123456789', 'Europe/Paris', '+02:00', 0, 253418061600, 123456789], + ['10000-03-26T01:59:59.999999999', 'Europe/Paris', '+01:00', 0, 253409648399, 999999999], + ['10000-03-26T02:00:00.000000000', 'Europe/Paris', '+02:00', 3600, 253409648400, 0], + ['10000-03-26T02:59:59.999999999', 'Europe/Paris', '+02:00', 3600, 253409651999, 999999999], + ['10000-03-26T03:00:00.000000000', 'Europe/Paris', '+02:00', 0, 253409648400, 0], + ['10000-10-29T01:59:59.999999999', 'Europe/Paris', '+02:00', 0, 253428393599, 999999999], + ['10000-10-29T02:00:00.000000000', 'Europe/Paris', '+01:00', 0, 253428397200, 0], + ['10000-10-29T02:59:59.999999999', 'Europe/Paris', '+01:00', 0, 253428400799, 999999999], + ['10000-10-29T03:00:00.000000000', 'Europe/Paris', '+01:00', 0, 253428400800, 0], ]; } + public function testOfWithOffsetUsesOffsetAsTimeZone(): void + { + $offset = TimeZoneOffset::of(2); + $zonedDateTime = ZonedDateTime::of(LocalDateTime::of(2020, 1, 1), $offset); + + self::assertSame($offset, $zonedDateTime->getTimeZone()); + self::assertSame($offset, $zonedDateTime->getTimeZoneOffset()); + } + #[DataProvider('providerOfInstant')] public function testOfInstant(string $formattedDatetime, string $timeZone): void { From 8f878cbd6f5cdc5935dde96e39f62df2b472e86f Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Tue, 6 Oct 2026 23:26:12 +0200 Subject: [PATCH 2/6] Fix ZonedDateTime::toNativeDateTime() beyond year 9999 and in DST overlaps Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe --- src/ZonedDateTime.php | 24 +++++++----------------- tests/LocalDateTimeTest.php | 3 +++ tests/ZonedDateTimeTest.php | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index fca1fa4f..03f80999 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -713,29 +713,19 @@ public function isPast(?Clock $clock = null): bool */ public function toNativeDateTime(): DateTime { - $second = $this->localDateTime->getSecond(); - // round down to the microsecond - $nano = $this->localDateTime->getNano(); - $nano = 1000 * intdiv($nano, 1000); - - $dateTime = (string) $this->localDateTime->withNano($nano); - $dateTimeZone = $this->timeZone->toNativeDateTimeZone(); - - $format = 'Y-m-d\TH:i'; + $micro = intdiv($this->instant->getNano(), 1000); - if ($second !== 0 || $nano !== 0) { - $format .= ':s'; + // The DateTime is created from the instant rather than from the local date-time, as the latter + // is ambiguous during a DST overlap, and DateTime only parses years 0000 to 9999. + $timestamp = $this->instant->getEpochSecond() . '.' . str_pad((string) $micro, 6, '0', STR_PAD_LEFT); - if ($nano !== 0) { - $format .= '.u'; - } - } - - $nativeDateTime = DateTime::createFromFormat($format, $dateTime, $dateTimeZone); + $nativeDateTime = DateTime::createFromFormat('U.u', $timestamp); assert($nativeDateTime !== false); + $nativeDateTime->setTimezone($this->timeZone->toNativeDateTimeZone()); + return $nativeDateTime; } diff --git a/tests/LocalDateTimeTest.php b/tests/LocalDateTimeTest.php index 867ec9a1..b0c71868 100644 --- a/tests/LocalDateTimeTest.php +++ b/tests/LocalDateTimeTest.php @@ -1184,6 +1184,9 @@ public static function providerToNativeDateTime(): array ['2011-07-31T23:59:59', '2011-07-31T23:59:59.000000+0000'], ['2011-07-31T23:59:59.02', '2011-07-31T23:59:59.020000+0000'], ['2011-07-31T23:59:59.000123456', '2011-07-31T23:59:59.000123+0000'], + ['-0001-01-01T00:00', '-0001-01-01T00:00:00.000000+0000'], + ['10000-01-01T00:00', '10000-01-01T00:00:00.000000+0000'], + ['999999-12-31T23:59:59.999999999', '999999-12-31T23:59:59.999999+0000'], ]; } diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php index 6e95845a..2a61d640 100644 --- a/tests/ZonedDateTimeTest.php +++ b/tests/ZonedDateTimeTest.php @@ -1113,9 +1113,32 @@ public static function providerToNativeDateTime(): array ['2011-07-31T23:59:59.02-05:30', '2011-07-31T23:59:59.020000-0530'], ['2011-07-31T23:59:59+01:00[Europe/London]', '2011-07-31T23:59:59.000000+0100'], ['2011-07-31T23:59:59.000123456-07:00', '2011-07-31T23:59:59.000123-0700'], + ['-999999-01-01T00:00Z', '-999999-01-01T00:00:00.000000+0000'], + ['-0001-12-31T23:59:59.5+01:00', '-0001-12-31T23:59:59.500000+0100'], + ['10000-01-01T00:00Z', '10000-01-01T00:00:00.000000+0000'], + ['10000-07-01T12:00+02:00[Europe/Paris]', '10000-07-01T12:00:00.000000+0200'], + ['999999-12-31T23:59:59.999999999Z', '999999-12-31T23:59:59.999999+0000'], ]; } + public function testToNativeDateTimeDuringOverlap(): void + { + $timeZone = TimeZone::parse('Europe/Paris'); + + // 2024-10-27T02:30 occurs twice in Europe/Paris: first at +02:00, then at +01:00 + $first = ZonedDateTime::ofInstant(Instant::of(1729989000), $timeZone)->toNativeDateTime(); + $second = ZonedDateTime::ofInstant(Instant::of(1729992600), $timeZone)->toNativeDateTime(); + + self::assertSame('2024-10-27T02:30:00+02:00', $first->format('Y-m-d\TH:i:sP')); + self::assertSame('2024-10-27T02:30:00+01:00', $second->format('Y-m-d\TH:i:sP')); + + self::assertSame(1729989000, $first->getTimestamp()); + self::assertSame(1729992600, $second->getTimestamp()); + + self::assertSame('Europe/Paris', $first->getTimezone()->getName()); + self::assertSame('Europe/Paris', $second->getTimezone()->getName()); + } + #[DataProvider('providerToString')] public function testJsonSerialize(string $localDateTime, string $timeZone, string $expectedString): void { From 13f09459e98b77d150f2fd1a6a8fcf1043ede96b Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Tue, 6 Oct 2026 23:38:30 +0200 Subject: [PATCH 3/6] Shorten comments Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe --- src/ZonedDateTime.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index 03f80999..9e9d3018 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -80,7 +80,7 @@ public static function of(LocalDateTime $dateTime, TimeZone $timeZone): ZonedDat + $dateTime->getTime()->toSecondOfDay(); if ($timeZone instanceof TimeZoneOffset) { - // A fixed offset has no DST transition: every local date-time is valid, and maps to a single instant. + // A fixed offset has no DST gap or overlap. $instant = Instant::of($localEpochSecond - $timeZone->getTotalSeconds(), $dateTime->getNano()); return new ZonedDateTime($dateTime, $timeZone, $timeZone, $instant); @@ -98,7 +98,7 @@ public static function of(LocalDateTime $dateTime, TimeZone $timeZone): ZonedDat $epochSecond = $dt->getTimestamp(); $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset()); - // The time is shifted forward if the date-time falls in a DST gap. + // Shifted forward in a DST gap. $shift = $epochSecond + $timeZoneOffset->getTotalSeconds() - $localEpochSecond; if ($shift !== 0) { @@ -716,8 +716,7 @@ public function toNativeDateTime(): DateTime // round down to the microsecond $micro = intdiv($this->instant->getNano(), 1000); - // The DateTime is created from the instant rather than from the local date-time, as the latter - // is ambiguous during a DST overlap, and DateTime only parses years 0000 to 9999. + // From the instant, as the local date-time is ambiguous in a DST overlap. $timestamp = $this->instant->getEpochSecond() . '.' . str_pad((string) $micro, 6, '0', STR_PAD_LEFT); $nativeDateTime = DateTime::createFromFormat('U.u', $timestamp); From 54ccc532485b974f8c67800b0fbb622bc4f914d5 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Tue, 6 Oct 2026 23:38:30 +0200 Subject: [PATCH 4/6] Speed up ZonedDateTime::ofInstant() Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe --- src/ZonedDateTime.php | 27 +++++++++++--------- tests/ZonedDateTimeTest.php | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index 9e9d3018..92eebb10 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -8,6 +8,7 @@ use Brick\DateTime\Parser\DateTimeParser; use Brick\DateTime\Parser\DateTimeParseResult; use Brick\DateTime\Parser\IsoParsers; +use Brick\DateTime\Utility\Math; use DateTime; use DateTimeImmutable; use DateTimeInterface; @@ -117,23 +118,25 @@ public static function of(LocalDateTime $dateTime, TimeZone $timeZone): ZonedDat */ public static function ofInstant(Instant $instant, TimeZone $timeZone): ZonedDateTime { - $dateTimeZone = $timeZone->toNativeDateTimeZone(); - - // We need to pass a DateTimeZone to avoid a PHP warning... - $dateTime = new DateTime('@' . $instant->getEpochSecond(), $dateTimeZone); - - // ... but this DateTimeZone is ignored because of the timestamp, so we set it again. - $dateTime->setTimezone($dateTimeZone); - - $localDateTime = LocalDateTime::parse($dateTime->format('Y-m-d\TH:i:s')); - $localDateTime = $localDateTime->withNano($instant->getNano()); - if ($timeZone instanceof TimeZoneOffset) { $timeZoneOffset = $timeZone; } else { - $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dateTime->getOffset()); + $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($timeZone->getOffset($instant)); } + // Split before adding the offset, to avoid overflowing. + $epochSecond = $instant->getEpochSecond(); + $epochDay = Math::floorDiv($epochSecond, LocalTime::SECONDS_PER_DAY); + $secondOfDay = Math::floorMod($epochSecond, LocalTime::SECONDS_PER_DAY) + $timeZoneOffset->getTotalSeconds(); + + $epochDay += Math::floorDiv($secondOfDay, LocalTime::SECONDS_PER_DAY); + $secondOfDay = Math::floorMod($secondOfDay, LocalTime::SECONDS_PER_DAY); + + $localDateTime = new LocalDateTime( + LocalDate::ofEpochDay($epochDay), + LocalTime::ofSecondOfDay($secondOfDay, $instant->getNano()), + ); + return new ZonedDateTime($localDateTime, $timeZoneOffset, $timeZone, $instant); } diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php index 2a61d640..e88b7cb0 100644 --- a/tests/ZonedDateTimeTest.php +++ b/tests/ZonedDateTimeTest.php @@ -5,6 +5,7 @@ namespace Brick\DateTime\Tests; use Brick\DateTime\Clock\FixedClock; +use Brick\DateTime\DateTimeException; use Brick\DateTime\DayOfWeek; use Brick\DateTime\Duration; use Brick\DateTime\Instant; @@ -26,6 +27,8 @@ use function json_encode; use const JSON_THROW_ON_ERROR; +use const PHP_INT_MAX; +use const PHP_INT_MIN; use const PHP_VERSION_ID; /** @@ -401,6 +404,53 @@ public static function providerOfInstant(): array ]; } + #[DataProvider('providerOfInstantEdgeCases')] + public function testOfInstantEdgeCases(int $epochSecond, int $nano, string $timeZone, string $expected): void + { + $zonedDateTime = ZonedDateTime::ofInstant(Instant::of($epochSecond, $nano), TimeZone::parse($timeZone)); + + self::assertSame($expected, (string) $zonedDateTime); + self::assertSame($epochSecond, $zonedDateTime->getEpochSecond()); + self::assertSame($nano, $zonedDateTime->getNano()); + } + + public static function providerOfInstantEdgeCases(): array + { + return [ + [-1, 999999999, 'Z', '1969-12-31T23:59:59.999999999Z'], + [0, 0, '+05:30:45', '1970-01-01T05:30:45+05:30:45'], + [0, 0, '-18:00', '1969-12-31T06:00:00-18:00'], + [1711846799, 0, 'Europe/Paris', '2024-03-31T01:59:59+01:00[Europe/Paris]'], + [1711846800, 0, 'Europe/Paris', '2024-03-31T03:00:00+02:00[Europe/Paris]'], + [1729989000, 0, 'Europe/Paris', '2024-10-27T02:30:00+02:00[Europe/Paris]'], + [1729992600, 0, 'Europe/Paris', '2024-10-27T02:30:00+01:00[Europe/Paris]'], + [-62167219200, 0, 'Z', '0000-01-01T00:00:00Z'], + [253402300800, 0, 'Europe/Paris', '10000-01-01T01:00:00+01:00[Europe/Paris]'], + [31494784780799, 999999999, 'Z', '999999-12-31T23:59:59.999999999Z'], + [-31619087596800, 0, '+18:00', '-999999-01-01T18:00:00+18:00'], + ]; + } + + #[DataProvider('providerOfInstantOutOfRangeThrowsException')] + public function testOfInstantOutOfRangeThrowsException(int $epochSecond, string $timeZone): void + { + $this->expectException(DateTimeException::class); + + ZonedDateTime::ofInstant(Instant::of($epochSecond), TimeZone::parse($timeZone)); + } + + public static function providerOfInstantOutOfRangeThrowsException(): array + { + return [ + [PHP_INT_MIN, 'Z'], + [PHP_INT_MIN, '-18:00'], + [PHP_INT_MAX, '+18:00'], + [PHP_INT_MAX, 'Europe/Paris'], + [31494784780800, 'Z'], + [-31619087596801, '-18:00'], + ]; + } + /** * @param string $text The string to parse. * @param string $date The expected date string. From a8fc794a46e554d03a772cddcfacf075cf02454c Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Tue, 6 Oct 2026 23:38:30 +0200 Subject: [PATCH 5/6] Cache the native DateTimeZone of TimeZoneOffset Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe --- src/TimeZoneOffset.php | 9 ++++++++- tests/TimeZoneOffsetTest.php | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/TimeZoneOffset.php b/src/TimeZoneOffset.php index f635997f..9e599687 100644 --- a/src/TimeZoneOffset.php +++ b/src/TimeZoneOffset.php @@ -25,6 +25,11 @@ final class TimeZoneOffset extends TimeZone */ private ?string $id = null; + /** + * Cached, as creating a DateTimeZone for `Z` is slow. + */ + private ?DateTimeZone $nativeDateTimeZone = null; + /** * Private constructor. Use a factory method to obtain an instance. * @@ -187,6 +192,8 @@ public function getOffset(Instant $pointInTime): int #[Override] public function toNativeDateTimeZone(): DateTimeZone { - return new DateTimeZone($this->getId()); + $this->nativeDateTimeZone ??= new DateTimeZone($this->getId()); + + return clone $this->nativeDateTimeZone; } } diff --git a/tests/TimeZoneOffsetTest.php b/tests/TimeZoneOffsetTest.php index b484dfcc..9aa8b670 100644 --- a/tests/TimeZoneOffsetTest.php +++ b/tests/TimeZoneOffsetTest.php @@ -294,6 +294,18 @@ public function testToNativeDateTimeZone(int $totalSeconds, string $string): voi self::assertSame($totalSeconds, $dateTimeZone->getOffset(new DateTimeImmutable())); } + public function testToNativeDateTimeZoneReturnsNewInstance(): void + { + $timeZoneOffset = TimeZoneOffset::utc(); + + $first = $timeZoneOffset->toNativeDateTimeZone(); + $second = $timeZoneOffset->toNativeDateTimeZone(); + + self::assertNotSame($first, $second); + self::assertSame('Z', $first->getName()); + self::assertSame('Z', $second->getName()); + } + public static function providerToNativeDateTimeZone(): iterable { yield from [ From dbc17cb8baa68931cb58bcd2093a7fb2bbeb75ae Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Thu, 8 Oct 2026 13:32:45 +0200 Subject: [PATCH 6/6] Cache only the native DateTimeZone for Z Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe --- src/TimeZoneOffset.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/TimeZoneOffset.php b/src/TimeZoneOffset.php index 9e599687..5b421493 100644 --- a/src/TimeZoneOffset.php +++ b/src/TimeZoneOffset.php @@ -25,11 +25,6 @@ final class TimeZoneOffset extends TimeZone */ private ?string $id = null; - /** - * Cached, as creating a DateTimeZone for `Z` is slow. - */ - private ?DateTimeZone $nativeDateTimeZone = null; - /** * Private constructor. Use a factory method to obtain an instance. * @@ -192,8 +187,20 @@ public function getOffset(Instant $pointInTime): int #[Override] public function toNativeDateTimeZone(): DateTimeZone { - $this->nativeDateTimeZone ??= new DateTimeZone($this->getId()); + $id = $this->getId(); + + if ($id === 'Z') { + return clone self::z(); + } + + return new DateTimeZone($id); + } + + private static function z(): DateTimeZone + { + /** @var DateTimeZone|null $z */ + static $z = null; - return clone $this->nativeDateTimeZone; + return $z ??= new DateTimeZone('Z'); } }