====== PHP RFC: Duration class ======
* Version: 1.0
* Date: 2026-06-18
* Author: Tim Düsterhus, tim@tideways-gmbh.com, Derick Rethans, derick@php.net
* Status: Under Discussion
* Implementation: https://github.com/...
* Discussion thread: https://news-web.php.net/php.internals/131376
* Voting thread: tbd
===== Introduction =====
Measuring and defining elapsed time is a ubiquitous concept in programming languages. Use cases where accurately defining such duration include defining timeouts for network requests, defining (exponential) back-off policies or defining operations that need to happen on a fixed schedule. PHP does not yet have a way to represent “stop-watch time” duration in a structured and intuitive way.
Instead existing APIs resort to int parameters representing a number of seconds, milliseconds, nanoseconds or any other unit that appears to be appropriate for a given use case. Sometimes durations are also split into separate parameters defining the $seconds and $microsecond separately. And sometimes it's a floating point value which comes with its own issues due to a possible lack of precision.
The existing DateInterval class is also unsuitable to define durations for the use-cases previously mentioned, due to its support for days and larger units which cannot be unambiguously mapped into a fixed number of seconds due to daylight saving time and months having different lengths.
This RFC proposes the addition a Time\Duration class that represents durations as measured by a “stop-watch” or an “egg-timer” in a structured way with nanosecond precision.
===== Proposal =====
Add a new always-available class Time\Duration with the following stub:
getTotalNanoseconds(): int`
*/
public readonly int $seconds;
public readonly int $nanoseconds;
/**
* Will be normalized to false if both $seconds and $nanoseconds are 0.
*/
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 constructor 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 duration string. ISO-8601 duration strings with a date component will be rejected.
* The biggest allowed component is H.
*/
public static function fromIso8601DurationString(string $specification): self
{
}
/**
* Negates the duration.
*
* @return self -$this
*/
public function negate(): self
{
}
/**
* Returns the duration with `$seconds` and `$nanoseconds` unchanged and
* `$negative` set to `false`.
*
* @return self abs($this)
*/
public function absolute(): 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. $factor must not be negative.
*
* @return self $this * $factor
*/
public function multiplyBy(int $factor): self
{
}
/**
* Divide the length of the duration by the given divisor. $divisor must be positive.
*
* 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
{
}
}
The Time\Duration class will also implement internal “comparison handlers”, which means that direct comparisons with operators such as < will work. Other operators (such as + for addition) will not be overloaded.
Additionally add the Time\TimeException base exception as per the [[https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables|Throwable policy]]:
namespace Time;
class TimeException extends \Exception { }
==== Naming of the mathematical operations ====
According to PHP’s naming policy “Abbreviations and acronyms as well as initialisms SHOULD be avoided wherever possible”, which is why the RFC proposes full verbs for multiplication, division, negation and taking absolutes.
At the same time the naming policy also specifies that “Diverging from this policy […] is allowed to keep internal consistency within a single extension, if the name follows an established, language-agnostic standard, or for other reasons, if those reasons are properly justified and voted on as part of the RFC process”.
While there naturally is no existing precedent within the extension (or namespace), there is cross-extension precedent for abbreviated names in ext/bcmath, ext/gmp, the standard library, and PHP’s own tokenizer, specifically:
* BcMath\Number::mul() BcMath\Number::div(), bcmul(), bcdiv()
* gmp_mul(), gmp_div(), gmp_neg(), gmp_abs()
* abs(), intdiv(), fdiv()
* T_MUL_EQUAL, T_DIV_EQUAL
There is also some cross-language precedent. JavaScript’s Temporal.Duration has ''abs()''. Rust’s std::time::Duration has ''checked_mul()'' and ''checked_div()''.
The RFC therefore leaves up the choice between full and abbreviated names up to a (weighted) secondary vote. For multiplication the ''By'' suffix is dropped when abbreviating. Division will keep the ''By'' suffix (''divBy'') to enable a future scope ''divInto'' method that divides a Duration by a Duration.
==== The new polling API ====
If this RFC is accepted for PHP 8.6, the polling API introduced in [[poll_api|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 {}
}
==== Passing Duration objects to APIs unable to handle nanosecond precision ====
This RFC leaves the behavior of passing a Time\Duration object that has more precision than an API can handle to the target API in question, since depending on the use case a different behavior might be the correct.
A non-exhaustive list of possible options:
* Always round *up* to the nearest representable value. This behavior makes most sense for “at least this amount of time” APIs (e.g. sleep()).
* Always round *down* to the nearest representable value.
* Throw for unrepresentable values. This behavior makes sense if the rounding error would be unacceptably large.
==== Design Considerations ====
Time\Duration is intended as the first part and basic building block of a modernized date and time library for PHP, while already providing value by itself. For this initial version the exposed API is intentionally small, while including all important parts for future classes to build onto.
Its design is roughly modeled after Golang’s ''time.Duration'', Java’s ''java.time.Duration'', and Rust’s ''std::time::Duration'', which just like Time\Duration are a structured representation of a number of nanoseconds.
The split between $seconds and $nanoseconds in the public API is intended to match how seconds are used as a base unit in human communication and science. It also improves compatibility with 32-bit versions of PHP, since signed 32-bit integers are sufficiently large to represent the number of nanoseconds in a second, while the separate seconds component allows to represent roughly 68 years. $seconds will be limited to 9_223_372_035 on 64-bit systems (roughly 292 years) to possibly allow representing the entire duration as a single nanosecond value in future scope.
Creating Duration objects happens via named constructors for all possible resolutions such that users are able to select the most appropriate resolution for their use case. Except for the fromSeconds() “base” constructor (which matches the second and nanosecond split described above) all constructors intentionally only take a single value in order to avoid multiple input combinations for the same constructor misleadingly resulting in the same duration due to overflow (e.g. 1 millisecond and 1500 microseconds being equivalent to 2 milliseconds and 500 microseconds). For the fromSeconds() constructor an overflow in the nanosecond component is rejected.
Negative durations are represented by an explicit $negative property. This makes it easy to deal with absolute values by just ignoring the value of $negative. It also avoids ambiguity with regard to signs for negative durations: Should both $seconds and $nanoseconds be negative for a negative duration?
Creating a negative duration intentionally requires a separate call to ->negate(), since use cases for those are expected to be rare. They are expected to mainly come up when subtracting one duration from another and when calculating differences between two “Instants” after more parts of the modernized date and time library are added.
==== Examples ====
Note that these examples show possible hypothetical use-cases. The RFC does not propose a change to existing functions to accept Time\Duration objects, except for the Io\Poll\Context as described previously.
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]
$baseDelay = Duration::fromMilliseconds(100);
$attempt = 5;
$delay = $baseDelay->multiplyBy(2 ** $attempt); // $delay is equal to 3.2 seconds
?>
A proof of concept user-land implementation is as follows. This implementation is non-authoritative and may contain bugs.
9_223_372_035) {
throw new TimeException('The maximum representable range is 9_223_372_035 seconds (roughly 292 years)');
}
\assert($seconds >= 0);
\assert($nanoseconds >= 0);
\assert($nanoseconds < 1_000_000_000);
\assert($seconds > 0 || $nanoseconds > 0 || !$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
{
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
{
if ($this->seconds === 0 && $this->nanoseconds === 0) {
return $this;
}
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. $factor must not be negative.
*
* @return self $this * $factor
*/
public function multiplyBy(int $factor): self
{
if ($factor < 0) {
throw new \ValueError('$factor must not be negative');
}
$seconds = $this->seconds * $factor;
$nanoseconds = $this->nanoseconds * $factor;
$seconds += \intdiv($nanoseconds, 1_000_000_000);
$nanoseconds = $nanoseconds % 1_000_000_000;
return new self($seconds, $nanoseconds, negative: $this->negative);
}
/**
* Divide the length of the duration by the given divisor. $divisor must be positive.
*
* Fractional nanoseconds will be truncated.
*
* @return self $this / $factor
*/
public function divideBy(int $divisor): self
{
if ($divisor <= 0) {
throw new \ValueError('$divisor must be positive');
}
$seconds = \intdiv($this->seconds, $divisor);
$nanoseconds = $this->nanoseconds + (($this->seconds % $divisor) * 1_000_000_000);
$nanoseconds = \intdiv($nanoseconds, $divisor);
return new self($seconds, $nanoseconds, negative: $this->negative);
}
/**
* 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:
* https://github.com/marc-mabe/php-timelib/blob/5092160dfae9c3bf9484a6c86c6809688f7341bd/polyfill/Duration.php#L21
* https://github.com/jiripudil/php-ext-time-prototype/blob/06b97b9e0c8f4878d68aee3a7571fc079f3ebc17/polyfill/src/Duration.php#L16
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:
* Calculating the difference between two points in time (“Instant”) will return a Time\Duration object.
* Calendar-based calculations with units larger than one hour (days, weeks, months) will use a dedicated class that only works with a full DateTime carrying both a time and date and a timezone. It will be possible to construct such a “calendar interval” from a Time\Duration, but not the other way around.
* There are two possible division operations on Durations: Duration by scalar and Duration by Duration. The naming of Duration by scalar was carefully selected to allow Duration by Duration (“divide into”) as future scope.
Additional classes will be added to the exception hierarchy when other classes are added (e.g. TimeOverflowException or DurationException).
===== Voting Choices =====
Primary Vote requiring a 2/3 majority to accept the RFC:
* Yes
* No
* Abstain
Secondary Vote to decide whether the “mathematical operation methods” should use full or abbreviated names. This vote is weighted and requires a 2/3 supermajority for abbreviated names to be selected.
* multiplyBy, dividedBy, negate, absolute
* mul, divBy, neg, abs
* Abstain
===== 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:
- the version(s) it was merged into
- a link to the git commit(s)
- a link to the PHP manual entry for the feature
===== References =====
* PHP Date/Time API Brainstorm document: https://docs.google.com/document/d/1pxPSRbfATKE4TFWw72K3p7ir-02YQbTf3S3SIxOKWsk/edit?usp=sharing
===== Rejected Features =====
- Adding a “unit” enum with a single constructor instead of multiple named constructors.
- 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 =====
* 2026-07-03: Fix mistake proof-of-concept implementation.
* 2026-07-03: Add secondary vote for naming of the mathematical operations.
* 2026-06-23: Clarify that every overflow will throw TimeException.
* 2026-06-23: Add ->absolute().
* 2026-06-23: Rename ->fromIso8601String() to ->fromIso8601DurationString().
* 2026-06-23: Limit seconds to 9_223_372_035 to enable possible future scope.
* 2026-06-23: Add TimeException.
* 2026-06-22: Clarify that the target API will be responsible for rounding if it cannot deal with nanosecond precision.
* 2026-06-22: Clarify that non-comparison operators will not be overloaded.
* 2026-06-18: Initial version.