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.
This commit is contained in:
dualfroz committed 2026-09-06 01:04:05 +02:00
1 parent d37085b46a
commit 491e7ada29
5 files changed
+67 -36

No files matched your search

+23 -1
View File
@@ -17,6 +17,10 @@ use Stringable;
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 @@ final readonly class ZonedDateTime implements JsonSerializable, Stringable
/**
* 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 . ']';