Files
brick-date-time/src/ZonedDateTime.php
T
dualfroz 6bb686d4af 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.
2026-09-04 08:53:18 +02:00

782 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace Brick\DateTime;
use Brick\DateTime\Parser\DateTimeParseException;
use Brick\DateTime\Parser\DateTimeParser;
use Brick\DateTime\Parser\DateTimeParseResult;
use Brick\DateTime\Parser\IsoParsers;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use JsonSerializable;
use Override;
use Stringable;
use function assert;
use function intdiv;
/**
* A date-time with a time-zone in the ISO-8601 calendar system.
*
* A ZonedDateTime can be viewed as a LocalDateTime along with a time zone
* and targets a specific point in time.
*/
final readonly class ZonedDateTime implements JsonSerializable, Stringable
{
/**
* Private constructor. Use a factory method to obtain an instance.
*
* @param LocalDateTime $localDateTime The local date-time.
* @param TimeZoneOffset $timeZoneOffset The time-zone offset from UTC/Greenwich.
* @param TimeZone $timeZone The time-zone. It is either a TimeZoneRegion if this ZonedDateTime is
* region-based, or the same instance as the offset if this ZonedDateTime
* is offset-based.
* @param Instant $instant The instant represented by this ZonedDateTime.
*/
private function __construct(
private LocalDateTime $localDateTime,
private TimeZoneOffset $timeZoneOffset,
private TimeZone $timeZone,
private Instant $instant,
) {
}
/**
* Creates a ZonedDateTime from a LocalDateTime and a TimeZone.
*
* This resolves the local date-time to an instant on the time-line.
*
* When a TimeZoneOffset is used, the local date-time can be converted to an instant without ambiguity.
*
* When a TimeZoneRegion is used, Daylight Saving Time can make the conversion more complex.
* There are 3 cases:
*
* - Normal: when there is only one valid offset for the date-time. The conversion is then as straightforward
* as when using a TimeZoneOffset. This is fortunately the case for the vast majority of the year.
* - Gap: when there is no valid offset for the date-time. This happens when the clock jumps forward
* typically due to a DST transition from "winter" to "summer". The date-times between the two times
* of the transition do not exist.
* - Overlap: when there are two valid offsets for the date-time. This happens when the clock is set back
* typically due to a DST transition from "summer" to "winter". The date-times between the two times
* of the transition can be resolved to two different offsets, representing two different instants
* on the time-line.
*
* The strategy for resolving gaps and overlaps is the following:
*
* - If the local date-time falls in the middle of a gap, then the resulting date-time will be shifted forward
* by the length of the gap, and the later offset, typically "summer" time, will be used.
* - If the local date-time falls in the middle of an overlap, then the offset closest to UTC will be used.
*/
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());
if ($timeZone instanceof TimeZoneOffset) {
$timeZoneOffset = $timeZone;
} else {
$timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset());
}
// 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());
return new ZonedDateTime($dateTime, $timeZoneOffset, $timeZone, $instant);
}
/**
* Creates a ZonedDateTime from an instant and a time zone.
*
* This resolves the instant to a date and time without ambiguity.
*/
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());
}
return new ZonedDateTime($localDateTime, $timeZoneOffset, $timeZone, $instant);
}
/**
* Returns the current date-time in the given time-zone, according to the given clock.
*
* If no clock is provided, the system clock is used.
*/
public static function now(TimeZone $timeZone, ?Clock $clock = null): ZonedDateTime
{
return ZonedDateTime::ofInstant(Instant::now($clock), $timeZone);
}
/**
* Obtains an instance of `ZonedDateTime` from a set of date-time fields.
*
* This method is only useful to parsers.
*
* @throws DateTimeException If the zoned date-time is not valid.
* @throws DateTimeParseException If required fields are missing from the result.
*/
public static function from(DateTimeParseResult $result): ZonedDateTime
{
$localDateTime = LocalDateTime::from($result);
$timeZoneOffset = TimeZoneOffset::from($result);
if ($result->hasField(Field\TimeZoneRegion::NAME)) {
$timeZone = TimeZoneRegion::from($result);
} else {
$timeZone = $timeZoneOffset;
}
return ZonedDateTime::of(
$localDateTime,
$timeZone,
);
}
/**
* Obtains an instance of `ZonedDateTime` from a text string.
*
* Valid examples:
* - `2007-12-03T10:15:30:45Z`
* - `2007-12-03T10:15:30+01:00`
* - `2007-12-03T10:15:30+01:00[Europe/Paris]`
*
* @param string $text The text to parse.
* @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser.
*
* @throws DateTimeException If the date is not valid.
* @throws DateTimeParseException If the text string does not follow the expected format.
*/
public static function parse(string $text, ?DateTimeParser $parser = null): ZonedDateTime
{
if ($parser === null) {
$parser = IsoParsers::zonedDateTime();
}
return ZonedDateTime::from($parser->parse($text));
}
/**
* Creates a ZonedDateTime from a native DateTime or DateTimeImmutable object.
*
* @throws DateTimeException If the DateTime object has no timezone.
*/
public static function fromNativeDateTime(DateTimeInterface $dateTime): ZonedDateTime
{
$localDateTime = LocalDateTime::fromNativeDateTime($dateTime);
$dateTimeZone = $dateTime->getTimezone();
if ($dateTimeZone === false) { // @phpstan-ignore identical.alwaysFalse
// @codeCoverageIgnoreStart
throw new DateTimeException('This DateTime object has no timezone.');
// @codeCoverageIgnoreEnd
}
$timeZone = TimeZone::fromNativeDateTimeZone($dateTimeZone);
if ($timeZone instanceof TimeZoneOffset) {
$timeZoneOffset = $timeZone;
} else {
$timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dateTime->getOffset());
}
$instant = Instant::of($dateTime->getTimestamp(), $localDateTime->getNano());
return new ZonedDateTime($localDateTime, $timeZoneOffset, $timeZone, $instant);
}
/**
* Returns the `LocalDateTime` part of this `ZonedDateTime`.
*/
public function getDateTime(): LocalDateTime
{
return $this->localDateTime;
}
/**
* Returns the `LocalDate` part of this `ZonedDateTime`.
*/
public function getDate(): LocalDate
{
return $this->localDateTime->getDate();
}
/**
* Returns the `LocalTime` part of this `ZonedDateTime`.
*/
public function getTime(): LocalTime
{
return $this->localDateTime->getTime();
}
public function getYear(): int
{
return $this->localDateTime->getYear();
}
/**
* Returns the month-of-year as a Month enum.
*/
public function getMonth(): Month
{
return $this->localDateTime->getMonth();
}
/**
* Returns the month-of-year value from 1 to 12.
*
* @return int<1, 12>
*/
public function getMonthValue(): int
{
return $this->localDateTime->getMonthValue();
}
/**
* @return int<1, 31>
*/
public function getDayOfMonth(): int
{
return $this->localDateTime->getDayOfMonth();
}
public function getDayOfWeek(): DayOfWeek
{
return $this->localDateTime->getDayOfWeek();
}
/**
* @return int<1, 366>
*/
public function getDayOfYear(): int
{
return $this->localDateTime->getDayOfYear();
}
public function getHour(): int
{
return $this->localDateTime->getHour();
}
public function getMinute(): int
{
return $this->localDateTime->getMinute();
}
public function getSecond(): int
{
return $this->localDateTime->getSecond();
}
public function getEpochSecond(): int
{
return $this->instant->getEpochSecond();
}
public function getNano(): int
{
return $this->instant->getNano();
}
/**
* Returns the time-zone, region or offset.
*/
public function getTimeZone(): TimeZone
{
return $this->timeZone;
}
/**
* Returns the time-zone offset.
*/
public function getTimeZoneOffset(): TimeZoneOffset
{
return $this->timeZoneOffset;
}
public function getInstant(): Instant
{
return $this->instant;
}
/**
* Returns a copy of this ZonedDateTime with a different date.
*/
public function withDate(LocalDate $date): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withDate($date), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with a different time.
*/
public function withTime(LocalTime $time): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withTime($time), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the year altered.
*/
public function withYear(int $year): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withYear($year), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the month-of-year altered.
*/
public function withMonth(int|Month $month): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withMonth($month), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the day-of-month altered.
*/
public function withDay(int $day): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withDay($day), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the hour-of-day altered.
*/
public function withHour(int $hour): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withHour($hour), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the minute-of-hour altered.
*/
public function withMinute(int $minute): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withMinute($minute), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the second-of-minute altered.
*/
public function withSecond(int $second): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withSecond($second), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the nano-of-second altered.
*/
public function withNano(int $nano): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->withNano($nano), $this->timeZone);
}
/**
* Returns a copy of this `ZonedDateTime` with a different time-zone,
* retaining the local date-time if possible.
*/
public function withTimeZoneSameLocal(TimeZone $timeZone): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime, $timeZone);
}
/**
* Returns a copy of this date-time with a different time-zone, retaining the instant.
*/
public function withTimeZoneSameInstant(TimeZone $timeZone): ZonedDateTime
{
return ZonedDateTime::ofInstant($this->instant, $timeZone);
}
/**
* Returns a copy of this date-time with the time-zone set to the offset.
*
* This returns a zoned date-time where the time-zone is the same as `getOffset()`.
* The local date-time, offset and instant of the result will be the same as in this date-time.
*
* Setting the date-time to a fixed single offset means that any future
* calculations, such as addition or subtraction, have no complex edge cases
* due to time-zone rules.
* This might also be useful when sending a zoned date-time across a network,
* as most protocols, such as ISO-8601, only handle offsets, and not region-based time zones.
*/
public function withFixedOffsetTimeZone(): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime, $this->timeZoneOffset);
}
/**
* Returns a copy of this ZonedDateTime with the specified Period added.
*/
public function plusPeriod(Period $period): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->plusPeriod($period), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified Duration added.
*/
public function plusDuration(Duration $duration): ZonedDateTime
{
return ZonedDateTime::ofInstant($this->instant->plus($duration), $this->timeZone);
}
/**
* Returns an Interval from this ZonedDateTime (inclusive) to the given one (exclusive).
*
* @throws DateTimeException If the given ZonedDateTime is before this ZonedDateTime.
*/
public function getIntervalTo(ZonedDateTime $that): Interval
{
return $this->getInstant()->getIntervalTo($that->getInstant());
}
/**
* Returns a Duration representing the time elapsed between this ZonedDateTime and the given one.
* This method will return a negative duration if the given ZonedDateTime is before the current one.
*/
public function getDurationTo(ZonedDateTime $that): Duration
{
return Duration::between($this->getInstant(), $that->getInstant());
}
/**
* Returns a copy of this ZonedDateTime with the specified period in years added.
*/
public function plusYears(int $years): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->plusYears($years), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in months added.
*/
public function plusMonths(int $months): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->plusMonths($months), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in weeks added.
*/
public function plusWeeks(int $weeks): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->plusWeeks($weeks), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in days added.
*/
public function plusDays(int $days): ZonedDateTime
{
return ZonedDateTime::of($this->localDateTime->plusDays($days), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in hours added.
*/
public function plusHours(int $hours): ZonedDateTime
{
return ZonedDateTime::ofInstant($this->instant->plusHours($hours), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in minutes added.
*/
public function plusMinutes(int $minutes): ZonedDateTime
{
return ZonedDateTime::ofInstant($this->instant->plusMinutes($minutes), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in seconds added.
*/
public function plusSeconds(int $seconds): ZonedDateTime
{
return ZonedDateTime::ofInstant($this->instant->plusSeconds($seconds), $this->timeZone);
}
/**
* Returns a copy of this ZonedDateTime with the specified Period subtracted.
*/
public function minusPeriod(Period $period): ZonedDateTime
{
return $this->plusPeriod($period->negated());
}
/**
* Returns a copy of this ZonedDateTime with the specified Duration subtracted.
*/
public function minusDuration(Duration $duration): ZonedDateTime
{
return $this->plusDuration($duration->negated());
}
/**
* Returns a copy of this ZonedDateTime with the specified period in years subtracted.
*/
public function minusYears(int $years): ZonedDateTime
{
return $this->plusYears(-$years);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in months subtracted.
*/
public function minusMonths(int $months): ZonedDateTime
{
return $this->plusMonths(-$months);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in weeks subtracted.
*/
public function minusWeeks(int $weeks): ZonedDateTime
{
return $this->plusWeeks(-$weeks);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in days subtracted.
*/
public function minusDays(int $days): ZonedDateTime
{
return $this->plusDays(-$days);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in hours subtracted.
*/
public function minusHours(int $hours): ZonedDateTime
{
return $this->plusHours(-$hours);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in minutes subtracted.
*/
public function minusMinutes(int $minutes): ZonedDateTime
{
return $this->plusMinutes(-$minutes);
}
/**
* Returns a copy of this ZonedDateTime with the specified period in seconds subtracted.
*/
public function minusSeconds(int $seconds): ZonedDateTime
{
return $this->plusSeconds(-$seconds);
}
/**
* Compares this ZonedDateTime with another.
*
* The comparison is performed on the instant.
*
* @return -1|0|1 If this zoned date-time is before, on, or after the given one.
*/
public function compareTo(ZonedDateTime $that): int
{
return $this->instant->compareTo($that->instant);
}
/**
* Returns whether this ZonedDateTime equals another.
*
* The comparison is performed on the instant.
*/
public function isEqualTo(ZonedDateTime $that): bool
{
return $this->compareTo($that) === 0;
}
/**
* Returns whether this ZonedDateTime is after another.
*
* The comparison is performed on the instant.
*/
public function isAfter(ZonedDateTime $that): bool
{
return $this->compareTo($that) === 1;
}
/**
* Returns whether this ZonedDateTime is after or equal to another.
*
* The comparison is performed on the instant.
*/
public function isAfterOrEqualTo(ZonedDateTime $that): bool
{
return $this->compareTo($that) >= 0;
}
/**
* Returns whether this ZonedDateTime is before another.
*
* The comparison is performed on the instant.
*/
public function isBefore(ZonedDateTime $that): bool
{
return $this->compareTo($that) === -1;
}
/**
* Returns whether this ZonedDateTime is before or equal to another.
*
* The comparison is performed on the instant.
*/
public function isBeforeOrEqualTo(ZonedDateTime $that): bool
{
return $this->compareTo($that) <= 0;
}
public function isBetweenInclusive(ZonedDateTime $from, ZonedDateTime $to): bool
{
return $this->isAfterOrEqualTo($from) && $this->isBeforeOrEqualTo($to);
}
public function isBetweenExclusive(ZonedDateTime $from, ZonedDateTime $to): bool
{
return $this->isAfter($from) && $this->isBefore($to);
}
/**
* Returns whether this ZonedDateTime is in the future, according to the given clock.
*
* If no clock is provided, the system clock is used.
*/
public function isFuture(?Clock $clock = null): bool
{
return $this->instant->isFuture($clock);
}
/**
* Returns whether this ZonedDateTime is in the past, according to the given clock.
*
* If no clock is provided, the system clock is used.
*/
public function isPast(?Clock $clock = null): bool
{
return $this->instant->isPast($clock);
}
/**
* Converts this ZonedDateTime to a native DateTime object.
*
* Note that the native DateTime object supports a precision up to the microsecond,
* so the nanoseconds are rounded down to the nearest microsecond.
*/
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';
if ($second !== 0 || $nano !== 0) {
$format .= ':s';
if ($nano !== 0) {
$format .= '.u';
}
}
$nativeDateTime = DateTime::createFromFormat($format, $dateTime, $dateTimeZone);
assert($nativeDateTime !== false);
return $nativeDateTime;
}
public function toNativeDateTimeImmutable(): DateTimeImmutable
{
return DateTimeImmutable::createFromMutable($this->toNativeDateTime());
}
/**
* Serializes as a string using {@see ZonedDateTime::toISOString()}.
*
* @return non-empty-string
*/
#[Override]
public function jsonSerialize(): string
{
return $this->toISOString();
}
/**
* Returns the ISO 8601 representation of this zoned date time.
*
* Unlike {@see LocalTime::toISOString()}, the time part always includes the
* seconds, so that the output is a valid RFC 3339 date-time. This type is
* primarily meant for interoperability, and RFC 3339 section 5.6 requires
* the full `HH:MM:SS` time even when the seconds are zero.
*
* @return non-empty-string
*/
public function toISOString(): string
{
$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 . ']';
}
return $string;
}
/**
* {@see ZonedDateTime::toISOString()}.
*
* @return non-empty-string
*/
#[Override]
public function __toString(): string
{
return $this->toISOString();
}
}