PHP 8.6's Time\Duration: No More Guessing Whether That Timeout Is Seconds or Milliseconds

The PHP 8.6 Duration class gives timeouts and backoff a real type. The full Time\Duration API, how it beats DateInterval, and the Laravel conversion glue.

Steven Richardson
Steven Richardson
· 8 min read

A job in one of my queues had public int $timeout = 30; and an HTTP client underneath it configured with retry(3, 30). One of those thirties is seconds. The other is milliseconds. Nobody noticed for four months, because a 30ms retry gap just looks like the API being flaky.

The PHP 8.6 Duration class exists to close that hole. PHP has never had a type for "how long": int $seconds, int $ms, float $seconds, two separate $seconds/$microseconds parameters — every API picks its own convention and the compiler helps you with none of it. Time\Duration fixes the type. It does not fix Laravel, which still wants integers, so most of this article is about the seam between the two.

PHP 8.6 reaches general availability on 19 November 2026. The Duration RFC passed 35–1 and is already merged, so the API below is final. It lands alongside partial function application, which is the headline feature of the same release.

The PHP 8.6 Duration class API#

Time\Duration is a final readonly class in the new Time namespace — PHP's first use of that namespace, and the foundation of a planned replacement for the date/time library. It represents stopwatch time: a count of seconds and nanoseconds, with no timezone, no calendar, no DST. If you've built readonly value objects in Laravel by hand, this is the same shape, just native.

You never call new. There is one named constructor per resolution:

use Time\Duration;

$quarterSecond = Duration::fromMilliseconds(250);
$fiveSeconds   = Duration::fromSeconds(5);
$precise       = Duration::fromSeconds(5, 500_000_000); // 5.5s — seconds + nanoseconds
$tenMinutes    = Duration::fromMinutes(10);
$oneHour       = Duration::fromHours(1);
$fromNanos     = Duration::fromNanoseconds(1_500_000_000); // 1.5s
$fromMicros    = Duration::fromMicroseconds(750);

// ISO-8601, time components only — PT1M30S is 90 seconds.
$fromIso = Duration::fromIso8601DurationString('PT1M30S');

fromSeconds() is the only constructor taking two arguments, and that is deliberate: it maps to the two base units the object actually stores. Every other constructor takes exactly one value, so fromMilliseconds(1) and fromMicroseconds(1500) can't quietly collapse into the same object through overflow.

Three public readonly properties, and that's the whole state:

$d = Duration::fromMilliseconds(2_500);

$d->seconds;      // 2
$d->nanoseconds;  // 500000000
$d->negative;     // false

Maths is method calls, and everything returns a new instance:

$base = Duration::fromSeconds(1);
$half = $base->divideBy(2);               // 0.5s
$total = $base->add($half);               // 1.5s
$left  = $total->sub(Duration::fromMilliseconds(200)); // 1.3s
$long  = $base->multiplyBy(90);           // 90s
$owed  = $base->negate();                 // -1s
$abs   = $owed->absolute();               // 1s

Comparison is where it gets pleasant. The class implements internal comparison handlers, so the operators work directly:

$budget  = Duration::fromMinutes(2);
$elapsed = Duration::fromNanoseconds(hrtime(true) - $startedAt);

if ($elapsed > $budget) {
    Log::warning('SLA budget blown', ['elapsed_ms' => $elapsed->seconds * 1000]);
}

// Sorting uses the static comparator.
usort($timeouts, Duration::compare(...)); // returns -1, 0 or 1

Only comparison operators are overloaded. $a + $b is a TypeError, not addition — use add().

Duration vs DateInterval#

DateInterval is calendar time and should stay that way. P1M is not a fixed number of seconds; adding a month to 31 January is a calendar question with a context-dependent answer, and the same is true of P1D across a DST boundary.

The split is clean in practice:

Question Type
"When does this subscription renew?" DateInterval / CarbonInterval
"How long do we wait before retrying?" Time\Duration
"What's the billing period?" DateInterval
"What's the lock TTL?" Time\Duration

Carbon's CarbonInterval sits in the calendar camp too, with the extra wrinkle that it's mutable by default. If you've already moved to CarbonImmutable for your date handling, Duration is the same instinct applied to the other half of the problem: an immutable value you can pass around without defensive copies.

One practical consequence: fromIso8601DurationString() rejects anything with a date component. The largest unit it accepts is H.

Duration::fromIso8601DurationString('PT30S');   // fine
Duration::fromIso8601DurationString('PT2H30M'); // fine
Duration::fromIso8601DurationString('P1D');     // throws — days are calendar units
Duration::fromHours(24);                        // say this instead

That rejection is a feature. A day is not always 86,400 seconds, and a timeout type that pretended otherwise would be lying.

Where the PHP 8.6 Duration class stops and Laravel begins#

Laravel 13 requires PHP 8.3 and supports 8.3 through 8.5. It has no Duration support, and realistically won't until PHP 8.6 is the floor for a major release. So Duration is an internal type for now: it lives in your domain and application layers, and you convert explicitly where the framework starts.

There's no ->milliseconds or ->toSeconds() on the class. Write the helper once:

namespace App\Time;

use Time\Duration;

final class Durations
{
    /**
     * Whole seconds, rounded up, so a sub-second duration never becomes 0.
     * Passing 0 to an API that reads it as "no timeout" is how outages happen.
     */
    public static function toWholeSeconds(Duration $d): int
    {
        $seconds = $d->seconds + ($d->nanoseconds > 0 ? 1 : 0);

        return $d->negative ? -$seconds : $seconds;
    }

    /** Truncates sub-millisecond precision. */
    public static function toMilliseconds(Duration $d): int
    {
        $ms = ($d->seconds * 1_000) + intdiv($d->nanoseconds, 1_000_000);

        return $d->negative ? -$ms : $ms;
    }

    /** For APIs that accept fractional seconds, like Http::timeout(). */
    public static function toFloatSeconds(Duration $d): float
    {
        $value = $d->seconds + ($d->nanoseconds / 1_000_000_000);

        return $d->negative ? -$value : $value;
    }
}

Now the units are visible at every call site:

use App\Time\Durations;
use Illuminate\Support\Facades\Http;
use Time\Duration;

$timeout    = Duration::fromSeconds(5);
$retryAfter = Duration::fromMilliseconds(250);

Http::timeout(Durations::toFloatSeconds($timeout))      // seconds
    ->retry(3, Durations::toMilliseconds($retryAfter))  // milliseconds
    ->get($endpoint);

Two different units, two different conversions, both of them in the diff where a reviewer can see them. That is the entire win.

Backoff in queued jobs

Job backoff() returns seconds. Building that array from Durations gives you a readable exponential curve instead of a magic literal:

namespace App\Jobs;

use App\Time\Durations;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Time\Duration;

class SyncInvoice implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    /**
     * 1s, 1s, 2s, 4s — Laravel wants whole seconds between attempts.
     *
     * @return array<int, int>
     */
    public function backoff(): array
    {
        $base = Duration::fromMilliseconds(500);

        return array_map(
            static fn (int $attempt): int => Durations::toWholeSeconds($base->multiplyBy(2 ** $attempt)),
            range(0, $this->tries - 2),
        );
    }
}

If the backoff is doing real work — jitter, per-tenant rate limits, circuit breaking — it belongs in job middleware rather than the job itself, and Duration travels into middleware just as cleanly.

TTLs from config

Cache::put() takes seconds or a DateTimeInterface. Store ISO-8601 strings in config, parse once at boot, and the unit is documented in the value itself:

// config/sync.php
return [
    'lock_ttl'     => env('SYNC_LOCK_TTL', 'PT5M'),
    'cache_ttl'    => env('SYNC_CACHE_TTL', 'PT1H'),
];
// app/Providers/AppServiceProvider.php
use Time\Duration;

$this->app->singleton(SyncSettings::class, fn (): SyncSettings => new SyncSettings(
    lockTtl:  Duration::fromIso8601DurationString(config('sync.lock_ttl')),
    cacheTtl: Duration::fromIso8601DurationString(config('sync.cache_ttl')),
));

Parse in the container, not at every call site — config:cache serialises arrays of scalars, so the strings survive caching and the objects are rebuilt per request. The same discipline pays off across a layered caching strategy, where the same TTL often has to be expressed in three different units for three different stores.

Gotchas and Edge Cases#

Almost nothing in core accepts a Duration. The RFC's opening example shows sleep(Duration::fromMilliseconds(500)), and that is aspirational — the RFC states plainly that it does not change existing functions. The one exception is Io\Poll\Context::wait() from the new polling API, which takes ?Time\Duration $timeout. Everything else, including every Laravel API, still wants an int or a float.

divideBy() truncates. Duration::fromSeconds(1)->divideBy(3) gives 333,333,333ns, not a repeating third. Three of those added together are a nanosecond short of a second. Fine for timeouts, wrong for anything you're reconciling.

Both maths methods are integer-only and sign-restricted. multiplyBy() throws ValueError on a negative factor; divideBy() throws on zero or negative. multiplyBy(0.5) doesn't exist — use divideBy(2).

Constructors reject negatives. You can't say fromSeconds(-1). Negative durations are reachable only through ->negate(), and they're flagged by the negative property rather than by signed fields, so $d->seconds is always ≥ 0. Any conversion helper you write has to check negative or it will silently drop the sign — mine does, above.

Seconds cap at 9,223,372,035. Roughly 292 years, deliberately short of PHP_INT_MAX so a future getTotalNanoseconds() can fit in one int. Overflow throws Time\TimeException, which extends \Exception — a checked-style error, not a ValueError, so your existing catch (\Exception) blocks will swallow it.

Rounding to zero is the dangerous conversion. intdiv(500_000_000, 1_000_000_000) is 0. Pass that to an API that reads 0 as "no timeout" or "retry immediately" and you've built a hot loop. Round up on the way out, which is why toWholeSeconds() above adds one whenever any nanoseconds remain.

Measure with hrtime(), not wall clock. Subtracting two DateTimeImmutable instances measures the clock, which NTP can move under you. Duration::fromNanoseconds(hrtime(true) - $start) measures elapsed time.

Wrapping Up#

Start by typing your own code: constructors, service methods and DTOs take Time\Duration, and a single helper class does the conversion where Laravel begins. That alone removes the unit ambiguity from every internal call, and it's a change you can make today with a small userland value object and swap for the native class when you move to 8.6.

Before you start, make sure the rest of the upgrade is clean — the PHP 8.5 deprecations cheat sheet covers what to clear out first. If the first place you'll use this is retry policy, scaling Laravel queues in production is where the backoff decisions actually get made.

FAQ#

What is the Duration class in PHP 8.6?

Time\Duration is a final readonly class added in PHP 8.6 that represents stopwatch time — an amount of elapsed time with nanosecond precision, independent of timezones and calendars. You build one with a named constructor like Duration::fromMilliseconds(250) or Duration::fromIso8601DurationString('PT1M30S'), and it exposes three readonly properties: seconds, nanoseconds and negative. It is the first class in PHP's new Time namespace and is intended as the foundation for a modernised date and time library.

What is the difference between DateInterval and Duration in PHP?

DateInterval models calendar time, so it can express months, days and years whose length depends on the date and timezone they're applied to. Time\Duration models stopwatch time, which is always a fixed number of seconds and nanoseconds. Use DateInterval for billing periods, subscription renewals and "the first Monday of next month"; use Duration for timeouts, retry delays, lock TTLs and SLA budgets. Duration enforces the distinction by rejecting any ISO-8601 string containing a date component.

How do I convert a PHP Duration to seconds or milliseconds?

There is no built-in toSeconds() or toMilliseconds() method. You read the public seconds and nanoseconds properties and do the arithmetic yourself, remembering that the sign lives in the separate negative property. Milliseconds are ($d->seconds * 1_000) + intdiv($d->nanoseconds, 1_000_000). For whole seconds, round up rather than truncating, or a 500ms duration becomes a zero-second timeout.

Can I use PHP's Duration with Laravel queues and HTTP timeouts?

Not directly. Laravel 13 targets PHP 8.3 to 8.5 and its APIs still take integers or floats — Http::timeout() wants seconds, Http::retry() wants milliseconds, job backoff() wants seconds, and Cache::put() wants seconds or a DateTimeInterface. The practical pattern is to use Duration throughout your own application code and convert explicitly at each framework boundary with a small helper class, which makes the unit conversion visible in code review instead of implied by a variable name.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.