[PHP-DEV] [RFC] Duration class

Hi TIm,

On Sat, Jul 4, 2026 at 12:14 AM Tim Düsterhus <tim@bastelstu.be> wrote:

Hi

On 7/3/26 18:29, Pierre Joye wrote:
> I like that new Duration, good new APIs. I was following the
> discussion and one thing keeps coming to my mind as an API mistake:
>
> public static function fromSeconds(int $seconds, int $nanoseconds = 0)
>
> Let me break down my thinking:
>
> fromSeconds implies I'm setting a duration in seconds. But there are
> two arguments — the second is nanoseconds, and milliseconds isn't
> representable at all. The name promises one unit and the signature
> quietly provides another.

Please see php.internals: Re: [RFC] Duration class; and the fourth
paragraph in the “design considerations”.

The $nanoseconds argument in the `fromSeconds()` constructor is
intentionally limited to “the number of nanoseconds in a second” which
means that the constructor can be reasoned about taking (fractional)
seconds - the base unit of Durations - as a fixed point decimal. I do
not consider this a break in the promise.

I do consider it breaks the promise. Additionally, it forces the user
to do mental calculation and remember an arbitrary bound of
999,999,999 that doesn't exist in any of the APIs you cited as
inspiration. Java's Duration.ofSeconds(long, long) explicitly
documents carrying excess nanoseconds into seconds with no cap,
Duration.ofSeconds(2, 1_000_000_001) produces the same value as
Duration.ofSeconds(3, 1). Rust's Duration::new does the same. Temporal
doesn't even carry by default. Temporal.Duration.from({ hours: 200,
minutes: 17 }) is valid and preserves both values as given. None of
the three reject or restrict a sub-unit's magnitude the way this
proposal does.

> That's correct, but I don't think it applies here. The fromSeconds
> issue is a naming defect regardless of language: the method name
> promises "seconds" and the signature quietly also takes nanoseconds.

As mentioned above, the $nanoseconds parameter is intentionally limited,
making the constructor consistent with the other constructors in that
the “unit that is mentioned in the name” may overflow into the larger
units to allow construction with the desired precision in a way that
makes sense for the given use case.

Extending this pattern to e.g. `fromMinutes()` would result in
`fromMinutes($minutes, $seconds = 0)` with $seconds being limited to 59.

However in the other direction, `fromMilliseconds($milliseconds,
$microseconds = 0)`, with $microseconds <= 999 would feel weird, because
one wouldn’t say 5 milliseconds and 500 microseconds, when they can just
move the comma and say 5500 microseconds.

The seconds boundary is where the units shift from a “metric” factor of
1000 to a factor of 60. Thus the special handling.

> PHP already supports named arguments, giving us the same clarity as
> Temporal object literal without borrowing any JS syntax:
>
> Duration::from(seconds: -30, nanoseconds: -500);
> Duration::from(seconds: -30, nanoseconds: 500); // error
> Duration::from(seconds: 30, nanoseconds: 500).negate();
>
> A single ::from() with named arguments would be unambiguous, easy to
> remember, and remove the need for the whole family of from<Unit>

A design goal of the constructors was that they are injective functions,
i.e. there must be at most one way of creating a Duration with a
specific length from any given constructor. This is in order to make it
easy to reason about the resulting value, without needing to perform
(mental) calculations.

If injectivity were the real goal, none of Java, Rust, or Temporal
would be usable APIs by that standard, they're explicitly
non-injective by design, and that hasn't stopped them from being the
reference implementations you're citing as inspiration.
Precision-preserving carry is the friendlier default; rejecting valid
input to protect a property the cited prior art doesn't itself protect
is what actually creates the mental overhead it is trying to avoid.

I also think having the from() would be an easy win from day 1, could
even allow string if no named argument for the ISO 8601 input.

cheers,
--
Pierre

@pierrejoye

Hey Tim,

On 03.07.26 22:56, Tim Düsterhus wrote:

- in the "Proposal" section example of the RFC text the `readonly` are
redundantly on the class and properties; in the later example you only
have it on the properties.

Good catch, that was a mistake. I've adjusted the proof of concept implementation to add the `readonly` on the class. I've kept the redundant `readonly` on the properties to be extra explicit.

Best regards
Tim Düsterhus

Alright, I earlier wasn't sure if you actually wanted to make the class readonly. I have a gut feeling that making the class readonly could backfire. I know you consider `Duration` an VO, but with all the methods that are likely coming who knows? Making all current properties readonly results in the same as making the class readonly; maybe it is worth not making the class itself readonly for the time being?

--

Cheers
Nick

Hi

On 03.07.26 23:51, Tim Düsterhus wrote:

Hi

On 7/3/26 22:17, Marc B. wrote:

The `fromSeconds` constructor breaks the flow of all other constructors
as this is not only about seconds.
Also, this directly exposes how it's handled internally to the API which
makes it hard to extend later on (maybe we want to support picoseconds
on 10 years).

“Nanosecond precision” is an integral part of the design of the class and folks will build assumptions around this. Changing the precision later will be something between hard to impossible, even if the `fromSeconds()` constructor wouldn't take a fractional part.

This is a property shared between all the other languages we looked at (except for Swift as mentioned by John Bafford): Everyone models their Durations with nanosecond precision and documents it as such.

This is a simplified example what I mean:

// Initial version
final class Duration {
public readonly $totalSeconds;
public int $milliseconds { get => (int)($this->microseconds / 1_000); } // 0 - 999
public readonly $microseconds; // 0 - 999_999
public static function from(/* ... */ int $seconds=0, int $milliseconds=0, int $microseconds=0)
}

// Next version - introduce nanoseconds
final class Duration {
public readonly int $totalSeconds;
public int $milliseconds { get => (int)($this->nanoseconds / 1_000_000); } // 0 - 999
public int $microseconds { get => (int)($this->nanoseconds / 1_000); } // 0 - 999_999
public readonly int $nanoseconds; // 0 - 999_999_999
public static function from(/* ... */ int $seconds=0, int $milliseconds=0, int $microseconds=0, int $nanoseconds=0)
}

// Next version - introduce picoseconds
final class Duration {
public readonly int $totalSeconds;
public int $milliseconds { get => (int)($this->picoseconds / 1_000_000_000); } // 0 - 999
public int $microseconds { get => (int)($this->picoseconds / 1_000_000); } // 0 - 999_999
public int $nanoseconds { get => (int)($this->picoseconds / 1_000); } // 0 - 999_999_999
public readonly int $picoseconds; // 0 - 999_999_999_999
public static function from(/* ... */ int $seconds=0, int $milliseconds=0, int $microseconds=0, int $nanoseconds=0, int $picoseconds=0)
}

Introducing nanoseconds and later on picoseconds in that example isn't a big deal anymore.

PHP should make things simpler - not more complex. There are plenty of
different duration systems out there a such values could come from as
input to a PHP application. The current Duration API forces you to
manually correctly calculate the value before passing it the constructor.

E.g.:
* time measurement using microtime(true)
* using hrtime(true) returns float on 32bit

For both of these, the `false` variant returns the value as a fixed-point decimal of seconds + fractional seconds, matching the “seconds with a fractional part” design of this RFC.

For hrtime() specifically, you can even do the following:

$duration = Duration::fromSeconds\(\.\.\.hrtime\(\)\);

which will do the right thing (except for the fact that `hrtime()` *technically* returns an Instant [1], not a Duration).

And of course the new date and time API would not be complete without also providing access to a high-precision (monotonic) clock in a way that cleanly interoperates with the new API.

[1] An instant with an unknown origin.

Why would you create a duration directly from a point-in-time ?

I like the idea of a general "from" constructor - but not restricted to
seconds + nanos but ALL supported units

This one could still be provided in future scope, but given my previous replies, you can probably assume that I don't consider it a good idea.

$seconds = 1;
$millis = 1_000;
$micros = 1_000_000;
$nanos = 1_000_000_000;
Duration::from(seconds: $seconds, milliseconds: $millis, microseconds:
$micros, nanoseconds: $nanos); // 4 seconds - yes please

I'd argue that if I have the use case of constructing a duration from a number of seconds *and* multiple different fractions of a second that each could overflow into larger scales, I should likely stop and consider whether what I'm doing is a good idea.

But even then:

Duration::fromSeconds\($seconds\)
    \-&gt;add\(Duration::fromMilliseconds\($millis\)\)
    \-&gt;add\(Duration::fromMicroseconds\($micros\)\)
    \-&gt;add\(Duration::fromNanoseconds\($nanos\)\);

will work and do the right thing.

The same applies to the nanoseconds argument of your fromSeconds()

Duration::fromSeconds($seconds)->add(Duration::fromNanoseconds($nanos))

For the common case of “seconds + fractional seconds”, the `fromSeconds()` constructor works (and going from fractional milliseconds to fractional nanoseconds is a `*1_000_000` operation that should be easy and obvious enough).

The hole point is to not need to manually calculate before and manually make sure it's in the expected range of the limited Duration constructor.

// not tested and I still might miss a case - but this should give a mental modal what you need to manually handle for such a simple function
function durationFromInput(int $seconds, int $microseconds): Duration {
if ($microseconds > 999_999 || $microseconds < -999_999) {
$seconds += intdiv($microseconds, 1_000_000);
$microseconds = $microseconds % 1_000_000;
}

 if \($microseconds &lt; 0\) \{
     $seconds \-= 1;
     $microseconds = 1\_000\_000 \- $microseconds;
 \}

 $negative = $seconds &lt; 0;
 if \($negative\) \{
     $seconds \*= \-1;
 \}

 if \(is\_float\($seconds\)\) \{
     throw new RangeError\(&#39;Integer overflow&#39;\);
 \}

 $duration = Duration::fromSeconds\($seconds, $microseconds \* 1\_000\);
 if \($negative\) \{
     $duration = $duration::negate\(\);
 \}

 return $duration;

}

Best regards
Tim Düsterhus

Hi

On 04.07.26 00:16, Tim Düsterhus wrote:

Hi

On 7/3/26 22:45, Marc B. wrote:

An alternative would be to use the same sign for nanoseconds as for
seconds (while 0 seconds is neither positive or negative)

Yes, this is effectively mentioned in the “Design Considerations” section of the RFC.

I think you missed my point and truncated my explanation example.

1.5s = 1 second + 500_000_000 nanoseconds
-1.5s = -1 seconds + -500_000_000 nanoseconds
-0.5s = 0 seconds + -500_000_000 nanoseconds

No negative flag needed to be handled separately.

"... if you are interested in absolute values, you have the magnitude
right there and can ignore the `$negative` flag."

No I can't
* adding two durations does NOT guaranties a value greater than the two
* passing a negative duration as timeout might throw
* ...

The use cases you listed there are all passing the Duration object somewhere else, which I do not consider to be a case of “if you are interested […]”.

The quoted sentence if referring to situations where your code is the end user of the Duration object and extracts the contents from it for further processing. And in those situations a sign-magnitude representation makes processing easier, because you have a single authoritative source of the sign, not one in each component and because you can extract the magnitude right away, without needing to flip the sign of each component.

If you care about positive values only - use `absolute()`
If you want to know the sign of the duration - the is still the is negative property - but it's a getter and not a required to be stored flag.

This does not answer the meaning difference of `seconds` vs.
`nanoseconds` - it's confusing naming that `seconds` is the total amount
of seconds in this duration but `nanoseconds` is the number of fractions
of a second.

A value-object, such as Duration, is uniquely described by the sum of its properties. And in this case, it's quite literally the sum of the $seconds and $nanoseconds.

You missed your own $negative flag

Am I get you right that you dislike property get hooks on value objects in general?

See [RFC] Duration class - Externals

I'm afraid I don't see how one would get the assumption that $seconds would redundantly be equal to ($nanoseconds / 1_000_000_000) or something like that when the object only has $seconds and $nanoseconds properties without also having $milliseconds, $minutes, or something else. The confusion would also be quickly cleared up just by looking at the object and seeing that the $nanoseconds value is always smaller than 1 billion.

In my opinion renaming the $nanoseconds to $fractionalNanoseconds would greatly decrease the ergonomics of the class.

I more thought about renaming `$seconds` into `$totalSeconds`.

Regards,
Marc

Hi

On 2026-07-04 04:45, Nick Sdot wrote:

Alright, I earlier wasn't sure if you actually wanted to make the class readonly. I have a gut feeling that making the class readonly could backfire. I know you consider `Duration` an VO, but with all the methods that are likely coming who knows? Making all current properties readonly results in the same as making the class readonly; maybe it is worth not making the class itself readonly for the time being?

Given that the class is also `final`, the `readonly` property on the class can be added and removed without a breaking change [1]. Never say never, but since Duration is a value-object there also won't be additional properties in the future, because existing code might not anticipate them and mishandle Duration objects that make use of the new properties. That's why it was important to include support for negative Durations right away.

Best regards
Tim Düsterhus

[1] Except that `readonly` also disallows dynamic properties, but those will be disallowed either way (that's the `strict-properties` in the stub).

Hi

On 2026-07-04 11:32, Marc B. wrote:

On 7/3/26 22:45, Marc B. wrote:

An alternative would be to use the same sign for nanoseconds as for
seconds (while 0 seconds is neither positive or negative)

Yes, this is effectively mentioned in the “Design Considerations” section of the RFC.

I think you missed my point and truncated my explanation example.

1.5s = 1 second + 500_000_000 nanoseconds
-1.5s = -1 seconds + -500_000_000 nanoseconds
-0.5s = 0 seconds + -500_000_000 nanoseconds

No negative flag needed to be handled separately.

I understood the suggestion, and disagree with it for the reasons mentioned later in the email.

The use cases you listed there are all passing the Duration object somewhere else, which I do not consider to be a case of “if you are interested […]”.

The quoted sentence if referring to situations where your code is the end user of the Duration object and extracts the contents from it for further processing. And in those situations a sign-magnitude representation makes processing easier, because you have a single authoritative source of the sign, not one in each component and because you can extract the magnitude right away, without needing to flip the sign of each component.

If you care about positive values only - use `absolute()`

Perhaps I should've better phrased it as “magnitude” instead of “positive values”. There are multiple situations where having the magnitude available straight away is useful. The simplest case would be printing the duration in some way, for example:

     printf(
         "%s%d.%09ds",
         $duration->negative ? "negative " : '',
         $duration->seconds,
         $duration->nanoseconds,
     );

Then converting it into the “Java representation” is also more easily done, because I don't need to deal with the sign on the nanoseconds:

     $duration = Duration::fromMilliseconds(1500)->negate();

     printf(
         "%ds %dns\n",
         ($duration->negative ? -1 : 1) * ($duration->seconds + $duration->negative),
         $duration->nanoseconds,
     );

If you want to know the sign of the duration - the is still the is negative property - but it's a getter and not a required to be stored flag.

It seems we are in agreement with regard to the usefulness of the ->negative property then.

The internal representation does not need to match the userland API and instead can choose any representation it wishes. With the restrictions written in the RFC internal implementation could decide to store the entire duration as a single `int64_t`. Or it could store it as a C `struct timespec`.

This does not answer the meaning difference of `seconds` vs.
`nanoseconds` - it's confusing naming that `seconds` is the total amount
of seconds in this duration but `nanoseconds` is the number of fractions
of a second.

A value-object, such as Duration, is uniquely described by the sum of its properties. And in this case, it's quite literally the sum of the $seconds and $nanoseconds.

You missed your own $negative flag

No, I did not. The context was the naming of the seconds and nanoseconds properties. Also, then last sentence was intended as a light-hearted pun, not as normative RFC text.

Am I get you right that you dislike property get hooks on value objects in general?

Yes.

I'm afraid I don't see how one would get the assumption that $seconds would redundantly be equal to ($nanoseconds / 1_000_000_000) or something like that when the object only has $seconds and $nanoseconds properties without also having $milliseconds, $minutes, or something else. The confusion would also be quickly cleared up just by looking at the object and seeing that the $nanoseconds value is always smaller than 1 billion.

In my opinion renaming the $nanoseconds to $fractionalNanoseconds would greatly decrease the ergonomics of the class.

I more thought about renaming `$seconds` into `$totalSeconds`.

I believe that this similarly increased verbosity without increasing clarity: It's very obvious when Duration exceeds 1 minute in length that this is the total number of seconds, because the value exceeds 60. And below that the difference doesn't matter. Also it's eassy to see that the class doesn't have $minutes or $hours properties.

Best regards
Tim Düsterhus

Hi

On 2026-07-04 11:09, Marc B. wrote:

This is a simplified example what I mean:

// Initial version
final class Duration {
public readonly $totalSeconds;
public int $milliseconds { get => (int)($this->microseconds / 1_000); } // 0 - 999
public readonly $microseconds; // 0 - 999_999
public static function from(/* ... */ int $seconds=0, int $milliseconds=0, int $microseconds=0)
}

// Next version - introduce nanoseconds
final class Duration {
public readonly int $totalSeconds;
public int $milliseconds { get => (int)($this->nanoseconds / 1_000_000); } // 0 - 999
public int $microseconds { get => (int)($this->nanoseconds / 1_000); } // 0 - 999_999
public readonly int $nanoseconds; // 0 - 999_999_999
public static function from(/* ... */ int $seconds=0, int $milliseconds=0, int $microseconds=0, int $nanoseconds=0)
}

// Next version - introduce picoseconds
final class Duration {
public readonly int $totalSeconds;
public int $milliseconds { get => (int)($this->picoseconds / 1_000_000_000); } // 0 - 999
public int $microseconds { get => (int)($this->picoseconds / 1_000_000); } // 0 - 999_999
public int $nanoseconds { get => (int)($this->picoseconds / 1_000); } // 0 - 999_999_999
public readonly int $picoseconds; // 0 - 999_999_999_999
public static function from(/* ... */ int $seconds=0, int $milliseconds=0, int $microseconds=0, int $nanoseconds=0, int $picoseconds=0)
}

Introducing nanoseconds and later on picoseconds in that example isn't a big deal anymore.

It would still be, because you might unknowingly lose precision when someone gives you a Duration object with nanoseconds filled in, but you can only deal with microseconds, because your code was written before nanoseconds were supported.. Rounding down / truncating is the wrong behavior in many situations. See the “Passing Duration objects to APIs unable to handle nanosecond precision” section in the RFC.

For both of these, the `false` variant returns the value as a fixed-point decimal of seconds + fractional seconds, matching the “seconds with a fractional part” design of this RFC.

For hrtime() specifically, you can even do the following:

$duration = Duration::fromSeconds\(\.\.\.hrtime\(\)\);

which will do the right thing (except for the fact that `hrtime()` *technically* returns an Instant [1], not a Duration).

And of course the new date and time API would not be complete without also providing access to a high-precision (monotonic) clock in a way that cleanly interoperates with the new API.

[1] An instant with an unknown origin.

Why would you create a duration directly from a point-in-time ?

I acknowledged in the parentheses that this modeling is not strictly correct from a semantic perspective, but it is meaningful:

As Morgan explained, an instant (“point in time”) is effectively an origin + a Duration. Since `hrtime()` returns an instant retrieved from a monotonic clock, the origin is unknown to you and the only information you know is the duration. Thus you can represent this as just the Duration without losing any information. The only legal operation is calculating the difference, since for that the origin cancels out ((origin + duration1) - (origin + duration2)) is just (duration1 - duration2).

I'd argue that if I have the use case of constructing a duration from a number of seconds *and* multiple different fractions of a second that each could overflow into larger scales, I should likely stop and consider whether what I'm doing is a good idea.

But even then:

Duration::fromSeconds\($seconds\)
    \-&gt;add\(Duration::fromMilliseconds\($millis\)\)
    \-&gt;add\(Duration::fromMicroseconds\($micros\)\)
    \-&gt;add\(Duration::fromNanoseconds\($nanos\)\);

will work and do the right thing.

The same applies to the nanoseconds argument of your fromSeconds()

Duration::fromSeconds($seconds)->add(Duration::fromNanoseconds($nanos))

No, this behaves differently from the `fromSeconds()` constructor due to possible overflow in the `$nanos` component. The `fromSeconds()` constructor takes a fixed-point decimal which is a single semantic value and not appropriately represented by an explicit addition operation.

For the common case of “seconds + fractional seconds”, the `fromSeconds()` constructor works (and going from fractional milliseconds to fractional nanoseconds is a `*1_000_000` operation that should be easy and obvious enough).

The hole point is to not need to manually calculate before and manually make sure it's in the expected range of the limited Duration constructor.

As mentioned in the previous paragraph: Each constructor takes a single “atomic value”. If you have an overflow in the microseconds, then it's no longer a single semantic value, but two values. It's the difference between “I have 1.5 seconds” and “I have 1 second and 500_000 microseconds” where the “and” is the addition operation.

When I'm presented a web form as a user and it asks for a Duration with two fields “seconds” and “microseconds”, then I would fully expect a 0 <= microseconds < 1_000_000 validation happening there, and not a silent overflow into the seconds field that I explicitly provided. Similarly when asked for a date with separate year, month, day inputs, I would expect 2026-06-31 to be rejected as invalid instead of silently adjusting it to 2026-07-01, because as a user semantically I'm thinking about entering a single semantic value.

Best regards
Tim Düsterhus

Hi

On 2026-07-04 02:42, Pierre Joye wrote:

The $nanoseconds argument in the `fromSeconds()` constructor is
intentionally limited to “the number of nanoseconds in a second” which
means that the constructor can be reasoned about taking (fractional)
seconds - the base unit of Durations - as a fixed point decimal. I do
not consider this a break in the promise.

I do consider it breaks the promise. Additionally, it forces the user
to do mental calculation and remember an arbitrary bound of
999,999,999 that doesn't exist in any of the APIs you cited as
inspiration. Java's Duration.ofSeconds(long, long) explicitly
documents carrying excess nanoseconds into seconds with no cap,
Duration.ofSeconds(2, 1_000_000_001) produces the same value as
Duration.ofSeconds(3, 1). Rust's Duration::new does the same. Temporal
doesn't even carry by default. Temporal.Duration.from({ hours: 200,
minutes: 17 }) is valid and preserves both values as given. None of
the three reject or restrict a sub-unit's magnitude the way this
proposal does.

See my reply to Marc as to how the `fromSeconds()` constructor is consistent with the other constructors in taking a single semantic value. If you want the overflow behavior, use an addition.

For the Rust comparison, I could imagine that this is a deliberate choice to make the constructor infallible. Requiring users to `Duration::new(seconds, nanos).expect("success")` every time they want to create a Duration from (known-good) values would be pretty unergonomic. And similarly panicking for nanosecond overflow would be unreasonable in case user input makes it into the function. PHP’s (unchecked) exceptions provide a reasonable middle-ground here, because users would be able to cleanly handle the ValueError if they desire to do so.

Java would again be closer to PHP here, but I'm not as familiar with how the Exception hierarchy is typically structured there.

A design goal of the constructors was that they are injective functions,
i.e. there must be at most one way of creating a Duration with a
specific length from any given constructor. This is in order to make it
easy to reason about the resulting value, without needing to perform
(mental) calculations.

If injectivity were the real goal, none of Java, Rust, or Temporal
would be usable APIs by that standard, they're explicitly
non-injective by design, and that hasn't stopped them from being the
reference implementations you're citing as inspiration.

Yes, *inspiration*. We looked at those to see how others approach the problem, but of course built our own list of requirements and/or goals based on constraints imposed by PHP and also based on our experience of working with dates and times both in PHP and other programming languages.

Best regards
Tim Düsterhus

Hi Tim,

On Tue, Jul 7, 2026 at 9:49 PM Tim Düsterhus <tim@bastelstu.be> wrote:

> If injectivity were the real goal, none of Java, Rust, or Temporal
> would be usable APIs by that standard, they're explicitly
> non-injective by design, and that hasn't stopped them from being the
> reference implementations you're citing as inspiration.

Yes, *inspiration*. We looked at those to see how others approach the
problem, but of course built our own list of requirements and/or goals
based on constraints imposed by PHP and also based on our experience of
working with dates and times both in PHP and other programming
languages.

I wonder, what are the constraints php has for this proposal? And how
PHP doing something different than almost any other language is the
result of these constraints?

Cheers,
--
Pierre

@pierrejoye