From 9dd1445216888a7a83d67482cd04d515cce97b62 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 08:53:18 +0200 Subject: [PATCH] fix: emit full seconds in Instant and ZonedDateTime toISOString (RFC 3339) Instant::toISOString() and ZonedDateTime::toISOString() emitted a compact time that dropped zero seconds (e.g. 2023-11-14T22:14Z), which is valid ISO 8601 but not valid RFC 3339. Section 5.6 of RFC 3339 requires the full HH:MM:SS time even when the seconds are zero. These types are meant for interoperability, so they must always emit RFC 3339 compliant output. Build the time part of ZonedDateTime::toISOString() explicitly with a mandatory seconds component instead of delegating to LocalTime's compact formatter. Instant delegates to ZonedDateTime, so it is fixed transitively; jsonSerialize() and __toString() route through toISOString(). LocalTime's documented compact format is intentional and left unchanged. This changes the serialized string representation and is therefore a BC break; fixtures that pinned the compact output are updated accordingly. --- src/Instant.php | 3 ++ src/ZonedDateTime.php | 24 +++++++++++++- tests/InstantTest.php | 11 ++++--- tests/IntervalTest.php | 2 +- tests/ZonedDateTimeTest.php | 63 +++++++++++++++++++------------------ 5 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/Instant.php b/src/Instant.php index 76c5afc..325f227 100644 --- a/src/Instant.php +++ b/src/Instant.php @@ -357,6 +357,9 @@ public function jsonSerialize(): string /** * Returns the ISO 8601 representation of this instant. * + * The time part always includes the seconds, as RFC 3339 section 5.6 + * requires, even when they are zero. + * * @return non-empty-string */ public function toISOString(): string diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php index 099375a..7ed36d5 100644 --- a/src/ZonedDateTime.php +++ b/src/ZonedDateTime.php @@ -17,6 +17,10 @@ use function assert; use function intdiv; +use function rtrim; +use function str_pad; + +use const STR_PAD_LEFT; /** * A date-time with a time-zone in the ISO-8601 calendar system. @@ -735,11 +739,29 @@ public function jsonSerialize(): string /** * Returns the ISO 8601 representation of this zoned date time. * + * Unlike {@see LocalTime::toISOString()}, the time part always includes the + * seconds, as RFC 3339 section 5.6 requires, even when they are zero. + * * @return non-empty-string */ public function toISOString(): string { - $string = $this->localDateTime . $this->timeZoneOffset; + $time = $this->localDateTime->getTime(); + + $hour = $time->getHour(); + $minute = $time->getMinute(); + $second = $time->getSecond(); + $nano = $time->getNano(); + + $string = $this->localDateTime->getDate() + . 'T' + . ($hour < 10 ? '0' . $hour : $hour) + . ':' + . ($minute < 10 ? '0' . $minute : $minute) + . ':' + . ($second < 10 ? '0' . $second : $second) + . ($nano !== 0 ? '.' . rtrim(str_pad((string) $nano, 9, '0', STR_PAD_LEFT), '0') : '') + . $this->timeZoneOffset; if ($this->timeZone instanceof TimeZoneRegion) { $string .= '[' . $this->timeZone . ']'; diff --git a/tests/InstantTest.php b/tests/InstantTest.php index 1e3a042..efbc2c9 100644 --- a/tests/InstantTest.php +++ b/tests/InstantTest.php @@ -573,11 +573,11 @@ public function testGetIntervalTo(int $second1, int $nano1, int $second2, int $n public static function providerGetIntervalTo(): array { return [ - [1672567200, 0, 1672567200, 0, '2023-01-01T10:00Z/2023-01-01T10:00Z'], - [1672567200, 0, 1672567210, 0, '2023-01-01T10:00Z/2023-01-01T10:00:10Z'], + [1672567200, 0, 1672567200, 0, '2023-01-01T10:00:00Z/2023-01-01T10:00:00Z'], + [1672567200, 0, 1672567210, 0, '2023-01-01T10:00:00Z/2023-01-01T10:00:10Z'], [1672567200, 1000000, 1672567210, 2000000, '2023-01-01T10:00:00.001Z/2023-01-01T10:00:10.002Z'], [1672567200, 1, 1672567200, 9, '2023-01-01T10:00:00.000000001Z/2023-01-01T10:00:00.000000009Z'], - [1672567200, 0, 1672653600, 0, '2023-01-01T10:00Z/2023-01-02T10:00Z'], + [1672567200, 0, 1672653600, 0, '2023-01-01T10:00:00Z/2023-01-02T10:00:00Z'], ]; } @@ -648,11 +648,14 @@ public static function providerToString(): array [-2000000000, 0, '1906-08-16T20:26:40Z'], [-1, 0, '1969-12-31T23:59:59Z'], [-1, 123, '1969-12-31T23:59:59.000000123Z'], - [0, 0, '1970-01-01T00:00Z'], + [0, 0, '1970-01-01T00:00:00Z'], [0, 123456, '1970-01-01T00:00:00.000123456Z'], [1, 0, '1970-01-01T00:00:01Z'], [1, 123456789, '1970-01-01T00:00:01.123456789Z'], [2000000000, 0, '2033-05-18T03:33:20Z'], + // whole minute and whole hour must keep the full ":00" seconds (RFC 3339 section 5.6) + [1700000040, 0, '2023-11-14T22:14:00Z'], + [1700002800, 0, '2023-11-14T23:00:00Z'], ]; } diff --git a/tests/IntervalTest.php b/tests/IntervalTest.php index 9fc4724..5bbd8de 100644 --- a/tests/IntervalTest.php +++ b/tests/IntervalTest.php @@ -222,7 +222,7 @@ public function testGetIntersectionWithInvalidParams(): void $interval2 = Interval::of(Instant::of(300000), Instant::of(400000)); $this->expectException(DateTimeException::class); - $this->expectExceptionMessage('Intervals "1970-01-02T03:46:40Z/1970-01-03T07:33:20Z" and "1970-01-04T11:20Z/1970-01-05T15:06:40Z" do not intersect.'); + $this->expectExceptionMessage('Intervals "1970-01-02T03:46:40Z/1970-01-03T07:33:20Z" and "1970-01-04T11:20:00Z/1970-01-05T15:06:40Z" do not intersect.'); $interval1->getIntersectionWith($interval2); } diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php index b612225..9d85adb 100644 --- a/tests/ZonedDateTimeTest.php +++ b/tests/ZonedDateTimeTest.php @@ -436,7 +436,7 @@ public static function providerFromNativeDateTime(): array { return [ ['2018-07-21 14:09:10.23456', 'America/Los_Angeles', '2018-07-21T14:09:10.23456-07:00[America/Los_Angeles]'], - ['2019-01-21 17:59', 'America/Los_Angeles', '2019-01-21T17:59-08:00[America/Los_Angeles]'], + ['2019-01-21 17:59', 'America/Los_Angeles', '2019-01-21T17:59:00-08:00[America/Los_Angeles]'], ['2019-01-23 09:10:11.123', '+05:30', '2019-01-23T09:10:11.123+05:30'], ]; } @@ -753,12 +753,12 @@ public static function providerPlusWeeks(): array ['2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]', 25, '2000-06-24T12:34:56.123456789-07:00[America/Los_Angeles]'], // https://github.com/brick/date-time/issues/115 - ['2025-03-23T01:00+01:00[Europe/Prague]', 1, '2025-03-30T01:00+01:00[Europe/Prague]'], - ['2025-03-23T01:30+01:00[Europe/Prague]', 1, '2025-03-30T01:30+01:00[Europe/Prague]'], - ['2025-03-23T02:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], - ['2025-03-23T02:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], - ['2025-03-23T03:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], - ['2025-03-23T03:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], + ['2025-03-23T01:00+01:00[Europe/Prague]', 1, '2025-03-30T01:00:00+01:00[Europe/Prague]'], + ['2025-03-23T01:30+01:00[Europe/Prague]', 1, '2025-03-30T01:30:00+01:00[Europe/Prague]'], + ['2025-03-23T02:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00:00+02:00[Europe/Prague]'], + ['2025-03-23T02:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30:00+02:00[Europe/Prague]'], + ['2025-03-23T03:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00:00+02:00[Europe/Prague]'], + ['2025-03-23T03:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30:00+02:00[Europe/Prague]'], ]; } @@ -782,12 +782,12 @@ public static function providerPlusDays(): array ['2000-04-03T12:34:56.123456789-07:00[America/Los_Angeles]', -2, '2000-04-01T12:34:56.123456789-08:00[America/Los_Angeles]'], // https://github.com/brick/date-time/issues/115 - ['2025-03-29T01:00+01:00[Europe/Prague]', 1, '2025-03-30T01:00+01:00[Europe/Prague]'], - ['2025-03-29T01:30+01:00[Europe/Prague]', 1, '2025-03-30T01:30+01:00[Europe/Prague]'], - ['2025-03-29T02:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], - ['2025-03-29T02:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], - ['2025-03-29T03:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], - ['2025-03-29T03:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], + ['2025-03-29T01:00+01:00[Europe/Prague]', 1, '2025-03-30T01:00:00+01:00[Europe/Prague]'], + ['2025-03-29T01:30+01:00[Europe/Prague]', 1, '2025-03-30T01:30:00+01:00[Europe/Prague]'], + ['2025-03-29T02:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00:00+02:00[Europe/Prague]'], + ['2025-03-29T02:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30:00+02:00[Europe/Prague]'], + ['2025-03-29T03:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00:00+02:00[Europe/Prague]'], + ['2025-03-29T03:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30:00+02:00[Europe/Prague]'], ]; } @@ -815,12 +815,12 @@ public static function providerPlusHours(): array ['2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]', 1, '2000-04-02T05:12:34.123456789-07:00[America/Los_Angeles]'], // https://github.com/brick/date-time/issues/115 - ['2025-03-29T01:00+01:00[Europe/Prague]', 24, '2025-03-30T01:00+01:00[Europe/Prague]'], - ['2025-03-29T01:30+01:00[Europe/Prague]', 24, '2025-03-30T01:30+01:00[Europe/Prague]'], - ['2025-03-29T02:00+01:00[Europe/Prague]', 24, '2025-03-30T03:00+02:00[Europe/Prague]'], - ['2025-03-29T02:30+01:00[Europe/Prague]', 24, '2025-03-30T03:30+02:00[Europe/Prague]'], - ['2025-03-29T03:00+01:00[Europe/Prague]', 24, '2025-03-30T04:00+02:00[Europe/Prague]'], - ['2025-03-29T03:30+01:00[Europe/Prague]', 24, '2025-03-30T04:30+02:00[Europe/Prague]'], + ['2025-03-29T01:00+01:00[Europe/Prague]', 24, '2025-03-30T01:00:00+01:00[Europe/Prague]'], + ['2025-03-29T01:30+01:00[Europe/Prague]', 24, '2025-03-30T01:30:00+01:00[Europe/Prague]'], + ['2025-03-29T02:00+01:00[Europe/Prague]', 24, '2025-03-30T03:00:00+02:00[Europe/Prague]'], + ['2025-03-29T02:30+01:00[Europe/Prague]', 24, '2025-03-30T03:30:00+02:00[Europe/Prague]'], + ['2025-03-29T03:00+01:00[Europe/Prague]', 24, '2025-03-30T04:00:00+02:00[Europe/Prague]'], + ['2025-03-29T03:30+01:00[Europe/Prague]', 24, '2025-03-30T04:30:00+02:00[Europe/Prague]'], ]; } @@ -848,10 +848,10 @@ public static function providerPlusMinutes(): array ['2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]', 1, '2000-04-02T03:02:01.654321-07:00[America/Los_Angeles]'], // https://github.com/brick/date-time/issues/115 - ['2025-03-30T01:30+01:00[Europe/Prague]', 50, '2025-03-30T03:20+02:00[Europe/Prague]'], - ['2025-03-30T01:30+01:00[Europe/Prague]', 100, '2025-03-30T04:10+02:00[Europe/Prague]'], - ['2025-03-30T03:20+02:00[Europe/Prague]', -50, '2025-03-30T01:30+01:00[Europe/Prague]'], - ['2025-03-30T04:10+02:00[Europe/Prague]', -100, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-30T01:30:00+01:00[Europe/Prague]', 50, '2025-03-30T03:20:00+02:00[Europe/Prague]'], + ['2025-03-30T01:30:00+01:00[Europe/Prague]', 100, '2025-03-30T04:10:00+02:00[Europe/Prague]'], + ['2025-03-30T03:20:00+02:00[Europe/Prague]', -50, '2025-03-30T01:30:00+01:00[Europe/Prague]'], + ['2025-03-30T04:10:00+02:00[Europe/Prague]', -100, '2025-03-30T01:30:00+01:00[Europe/Prague]'], ]; } @@ -878,10 +878,10 @@ public static function providerPlusSeconds(): array ['2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]', 1, '2000-04-02T03:00:02.654321-07:00[America/Los_Angeles]'], // https://github.com/brick/date-time/issues/115 - ['2025-03-30T01:30+01:00[Europe/Prague]', 3000, '2025-03-30T03:20+02:00[Europe/Prague]'], - ['2025-03-30T01:30+01:00[Europe/Prague]', 6000, '2025-03-30T04:10+02:00[Europe/Prague]'], - ['2025-03-30T03:20+02:00[Europe/Prague]', -3000, '2025-03-30T01:30+01:00[Europe/Prague]'], - ['2025-03-30T04:10+02:00[Europe/Prague]', -6000, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-30T01:30:00+01:00[Europe/Prague]', 3000, '2025-03-30T03:20:00+02:00[Europe/Prague]'], + ['2025-03-30T01:30:00+01:00[Europe/Prague]', 6000, '2025-03-30T04:10:00+02:00[Europe/Prague]'], + ['2025-03-30T03:20:00+02:00[Europe/Prague]', -3000, '2025-03-30T01:30:00+01:00[Europe/Prague]'], + ['2025-03-30T04:10:00+02:00[Europe/Prague]', -6000, '2025-03-30T01:30:00+01:00[Europe/Prague]'], ]; } @@ -1086,6 +1086,9 @@ public static function providerToString(): array return [ ['2000-01-20T12:34:56.123456789', 'America/Los_Angeles', '2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], ['2000-01-20T12:34:56.123456789', '-07:00', '2000-01-20T12:34:56.123456789-07:00'], + // whole minute and whole hour must keep the full ":00" seconds (RFC 3339 section 5.6) + ['2000-01-20T12:34', '-07:00', '2000-01-20T12:34:00-07:00'], + ['2000-01-20T12:00', 'America/Los_Angeles', '2000-01-20T12:00:00-08:00[America/Los_Angeles]'], ]; } @@ -1121,13 +1124,13 @@ public function testGetIntervalTo(string $firstDate, string $secondDate, string public static function providerGetIntervalTo(): array { return [ - ['2023-01-01T10:00:00Z', '2023-01-01T10:00:00Z', '2023-01-01T10:00Z/2023-01-01T10:00Z'], - ['2023-01-01T10:00:00Z', '2023-01-01T10:00:10Z', '2023-01-01T10:00Z/2023-01-01T10:00:10Z'], + ['2023-01-01T10:00:00Z', '2023-01-01T10:00:00Z', '2023-01-01T10:00:00Z/2023-01-01T10:00:00Z'], + ['2023-01-01T10:00:00Z', '2023-01-01T10:00:10Z', '2023-01-01T10:00:00Z/2023-01-01T10:00:10Z'], ['2023-01-01T10:00:00.001Z', '2023-01-01T10:00:10.002Z', '2023-01-01T10:00:00.001Z/2023-01-01T10:00:10.002Z'], ['2023-01-01T10:00:00.001Z', '2023-01-01T13:00:10.002+03:00', '2023-01-01T10:00:00.001Z/2023-01-01T10:00:10.002Z'], ['2023-01-01T10:00:00.001+03:00', '2023-01-01T13:00:10.002+03:00', '2023-01-01T07:00:00.001Z/2023-01-01T10:00:10.002Z'], ['2023-01-01T10:00:00.000000001Z', '2023-01-01T10:00:00.000000009Z', '2023-01-01T10:00:00.000000001Z/2023-01-01T10:00:00.000000009Z'], - ['2023-01-01T10:00:00Z', '2023-01-02T10:00:00Z', '2023-01-01T10:00Z/2023-01-02T10:00Z'], + ['2023-01-01T10:00:00Z', '2023-01-02T10:00:00Z', '2023-01-01T10:00:00Z/2023-01-02T10:00:00Z'], ]; }