Table of Contents

PHP RFC: Duration class

Introduction

<?php
 
// Sleep for half a second
sleep(\Time\Duration::fromMilliseconds(500));
 
?>

Proposal

Add a new class Time\Duration with the following stub:

<?php
 
namespace Time;
 
/**
 * @strict-properties
 */
final class Duration
{
    public readonly int $seconds;
    public readonly int $nanoseconds;
    public readonly bool $negative;
 
    /**
     * Create a duration representing $seconds seconds and $nanoseconds nanoseconds. Neither parameter
     * may be negative. $nanoseconds must be less than 1_000_000_000 (the number of nanoseconds in a
     * second).
     *
     * This contructor creates a Duration from its “atomic” components.
     */
    public static function fromSeconds(int $seconds, int $nanoseconds = 0): self
    {
    }
 
    /**
     * Create a duration representing $nanoseconds nano-seconds. $nanoseconds must not be negative.
     */
    public static function fromNanoseconds(int $nanoseconds): self
    {
    }
 
    /**
     * Create a duration representing $microseconds micro-seconds. $microseconds must not be negative.
     */
    public static function fromMicroseconds(int $microseconds): self
    {
    }
 
    /**
     * Create a duration representing $milliseconds milli-seconds. $milliseconds must not be negative.
     */
    public static function fromMilliseconds(int $milliseconds): self
    {
    }
 
    /**
     * Create a duration representing $minutes minutes. $minutes must not be negative.
     */
    public static function fromMinutes(int $minutes): self
    {
    }
 
    /**
     * Create a duration representing $hours hours. $hours must not be negative.
     */
    public static function fromHours(int $hours): self
    {
    }
 
    /**
     * Parse a ISO-8601 period. ISO-8601 periods with a date component will be rejected.
     * The biggest allowed component is H.
     */
    public static function fromIso8601String(string $specification): self
    {
    }
 
    /**
     * Negates the duration.
     *
     * @return self -$this
     */
    public function negate(): self
    {
    }
 
    /**
     * Add the given duration to the duration.
     *
     * @return self $this + $duration
     */
    public function add(self $duration): self
    {
    }
 
    /**
     * Subtract the given duration from the duration.
     *
     * @return self $this - $duration
     */
    public function sub(self $duration): self
    {
    }
 
    /**
     * Multiply the length of the duration by the given factor:
     * 
     * @return self $this * $factor
     */
    public function multiplyBy(int $factor): self
    {
    }
 
    /**
     * Divide the length of the duration by the given divisor.
     *
     * Fractional nanoseconds will be truncated.
     *
     * @return self $this / $factor
     */
    public function divideBy(int $divisor): self
    {
    }
 
    /**
     * Returns -1, 0, 1 if $a is less than, equal to, or greater than $b respectively.
     */
    public static function compare(self $a, self $b): int
    {
    }
}

If this RFC is accepted for PHP 8.6, the polling API introduced in PHP RFC: Polling API for PHP 8.6 will be adjusted to make use of the Time\Duration class instead of separate $timeout and $timeoutMicroseconds parameters:

namespace Io\Poll;
 
final class Context
{
    /**
     * […]
     * 
     * @param ?Time\Duration $timeout Timeout.
     *        null means wait indefinitely.
     *        Time\Duration::fromNanoseconds(0) means return immediately (non-blocking poll).
     *        Must be !$timeout->negative
     * 
     * […]
     */
    public function wait(
        ?Time\Duration $timeout = null,
        ?int $maxEvents = null
    ): array {}
}

Examples

Note that these examples show possible hypothetical use-cases. The RFC does not propose a change to existing functions, except for the Io\Poll\Context as described previously.

<?php
 
use Time\Duration;
 
$oneSecond = Duration::fromSeconds(1);
$halfSecond = $oneSecond->divideBy(2);
 
$onePointFiveSeconds = $oneSecond->add($halfSecond);
 
// Sleep for 1.5 seconds
sleep($onePointFiveSeconds);
 
$negativeHour = Duration::fromHours(1)->negate();
 
$durations = [
    $oneSecond,
    $negativeHour,
    $onePointFiveSeconds,
    $halfSecond,
];
 
usort($durations, Duration::compare(...)); // Will sort to [-1h, 0.5s, 1s, 1.5s]
 
?>

A proof of concept user-land implementation is as follows. This implementation is non-authoritative and may contain bugs.

proof_of_concept.php
<?php
 
namespace Time;
 
/**
 * @strict-properties
 */
final class Duration
{
    private function __construct(
        public readonly int $seconds,
        public readonly int $nanoseconds,
        public readonly bool $negative,
    ) {
        \assert($seconds >= 0);
        \assert($nanoseconds >= 0);
        \assert($nanoseconds < 1_000_000_000);
    }
 
    /**
     * Create a duration representing $seconds seconds and $nanoseconds nanoseconds. Neither parameter
     * may be negative. $nanoseconds must be less than 1_000_000_000 (the number of nanoseconds in a
     * second).
     *
     * This contructor creates a Duration from its “atomic” components.
     */
    public static function fromSeconds(int $seconds, int $nanoseconds = 0): self
    {
        if ($seconds < 0) {
            throw new \ValueError('$seconds must not be negative');
        }
        if ($nanoseconds < 0 || $nanoseconds >= 1_000_000_000) {
            throw new \ValueError('$nanoseconds must be between 0 and 999_999_999');
        }
 
        return new self($seconds, $nanoseconds, false);
    }
 
    /**
     * Create a duration representing $nanoseconds nano-seconds. $nanoseconds must not be negative.
     */
    public static function fromNanoseconds(int $nanoseconds): self
    {
        $seconds = \intdiv($nanoseconds, 1_000_000_000);
        $nanoseconds = $nanoseconds % 1_000_000_000;
 
        return self::fromSeconds($seconds, $nanoseconds);
    }
 
    /**
     * Create a duration representing $microseconds micro-seconds. $microseconds must not be negative.
     */
    public static function fromMicroseconds(int $microseconds): self
    {
        return self::fromNanoseconds($microseconds * 1_000);
    }
 
    /**
     * Create a duration representing $milliseconds milli-seconds. $milliseconds must not be negative.
     */
    public static function fromMilliseconds(int $milliseconds): self
    {
        return self::fromMicroseconds($milliseconds * 1_000);
    }
 
    /**
     * Create a duration representing $minutes minutes. $minutes must not be negative.
     */
    public static function fromMinutes(int $minutes): self
    {
        return self::fromSeconds($minutes * 60);
    }
 
    /**
     * Create a duration representing $hours hours. $hours must not be negative.
     */
    public static function fromHours(int $hours): self
    {
        return self::fromMinutes($hours * 60);
    }
 
    /**
     * Parse a ISO-8601 period. ISO-8601 periods with a date component will be rejected.
     * The biggest allowed component is H.
     */
    public static function fromIso8601String(string $specification): self
    {
        /* Omitted */
    }
 
    /**
     * Negates the duration.
     *
     * @return self -$this
     */
    public function negate(): self
    {
        return clone($this, ['negative' => !$this->negative]);
    }
 
    /**
     * Add the given duration to the duration.
     *
     * @return self $this + $duration
     */
    public function add(self $duration): self
    {
        /* (+x) + (-y) == (+x) - (+y) */
        if (!$this->negative && $duration->negative) {
            return $this->sub($duration->negate());
        }
        /* (-x) + (+y) == (+y) - (+x) */
        if ($this->negative && !$duration->negative) {
            return $duration->sub($this->negate());
        }
        /* (-x) + (-y) = -((+x) + (+y))  */
        if ($this->negative && $duration->negative) {
            return $this->negate()->add($duration->negate())->negate();
        }
 
        \assert(!$this->negative);
        \assert(!$duration->negative);
 
        $seconds = $this->seconds + $duration->seconds;
        $nanoseconds = $this->nanoseconds + $duration->nanoseconds;
 
        $seconds += \intdiv($nanoseconds, 1_000_000_000);
        $nanoseconds = $nanoseconds % 1_000_000_000;
 
        return self::fromSeconds($seconds, $nanoseconds);
    }
 
    /**
     * Subtract the given duration from the duration.
     *
     * @return self $this - $duration
     */
    public function sub(self $duration): self
    {
        /* (+x) - (-y) == (+x) + (+y) */
        if (!$this->negative && $duration->negative) {
            return $this->add($duration->negate());
        }
        /* (-x) - (+y) == -((+x) + (+y)) */
        if ($this->negative && !$duration->negative) {
            return $this->negate()->add($duration)->negate();
        }
        /* (-x) - (-y) == (-x) + (+y) */
        if ($this->negative && $duration->negative) {
            return $this->add($duration->negate());
        }
 
        \assert(!$this->negative);
        \assert(!$duration->negative);
 
        if (self::compare($this, $duration) >= 0) {
            $seconds = $this->seconds - $duration->seconds;
            $nanoseconds = $this->nanoseconds - $duration->nanoseconds;
            if ($nanoseconds < 0) {
                $nanoseconds += 1_000_000_000;
                $seconds--;
            }
 
            return self::fromSeconds($seconds, $nanoseconds);
        } else {
            /* (+x) - (+y) = -((+y) - (+x)) */
            return $duration->sub($this)->negate();
        }
    }
 
    /**
     * Multiply the length of the duration by the given factor:
     * 
     * @return self $this * $factor
     */
    public function multiplyBy(int $factor): self
    {
        $seconds = $this->seconds * $factor;
        $nanoseconds = $this->nanoseconds * $factor;
 
        $seconds += \intdiv($nanoseconds, 1_000_000_000);
        $nanoseconds = $nanoseconds % 1_000_000_000;
 
        return clone($this, ['seconds' => $seconds, 'nanoseconds' => $nanoseconds]);
    }
 
    /**
     * Divide the length of the duration by the given divisor.
     *
     * Fractional nanoseconds will be truncated.
     *
     * @return self $this / $factor
     */
    public function divideBy(int $divisor): self
    {
        $seconds = \intdiv($this->seconds, $divisor);
        $nanoseconds = $this->nanoseconds + (($this->seconds % $divisor) * 1_000_000_000);
        $nanoseconds = \intdiv($nanoseconds, $divisor);
 
        return clone($this, ['seconds' => $seconds, 'nanoseconds' => $nanoseconds]);
    }
 
    /**
     * Returns -1, 0, 1 if $a is less than, equal to, or greater than $b respectively.
     */
    public static function compare(self $a, self $b): int
    {
        if ($a->negative && !$b->negative) {
            return -1;
        }
        if (!$a->negative && $b->negative) {
            return 1;
        }
 
        if ($a->negative && $b->negative) {
            return - ($a->seconds <=> $b->seconds) ?: - ($a->nanoseconds <=> $b->nanoseconds);
        }
 
        if (!$a->negative && !$b->negative) {
            return ($a->seconds <=> $b->seconds) ?: ($a->nanoseconds <=> $b->nanoseconds);
        }
 
        throw new \Error('unreachable');
    }
}

Backward Incompatible Changes

This RFC is introducing a single class and starts using the Time for PHP’s standard library. Adding new symbols is not considered a breaking change as per our policy. The naming of the class and namespace is in line with PHP’s naming policy.

A GitHub search for language:php “namespace Time;” symbol:Duration reveals a total of 7 hits. There are two notable hits with:

which both try to solve the same problem in a very similar way and confirming there is an interest in having this API.

Proposed PHP Version(s)

Next minor (8.6).

RFC Impact

To the Ecosystem

None.

To Existing Extensions

Existing extensions may want to start using this new class where-ever a duration is required.

To SAPIs

None.

Open Issues

None.

Future Scope

The introduction of this class and the associated Time namespace is intended to lay the groundwork for a new date and time library in upcoming PHP versions. It is intentionally designed with a minimal, but useful, API that future additions will be able to build on.

Some ideas that have been taken into account when designing the Time\Duration class:

Voting Choices

Primary Vote requiring a 2/3 majority to accept the RFC:

Implement the Time\Duration class as outlined in the RFC?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

Links to proof of concept PR.

If there is no patch, make it clear who will create a patch, or whether a volunteer to help with implementation is needed.

Implementation

After the RFC is implemented, this section should contain:

  1. the version(s) it was merged into
  2. a link to the git commit(s)
  3. a link to the PHP manual entry for the feature

References

Rejected Features

  1. Adding a “unit” enum with a single constructor instead of multiple named constructors.
  2. Adding constructors with multiple (named) parameters, such as “hoursAndMinutes”. The fromSeconds() constructor is an intentional exception, because it works with the two “base units”.

Changelog