PHP 8.6 Duration: a small class that fixes a recurring design mistake

· Oskar Stark · Expertise · 5 minutes to read
Abstract editorial concept of a Duration class, showing elapsed time and precise time intervals in modern PHP coding

PHP 8.6 is expected to add a dedicated Duration class. That sounds small, but it finally gives PHP projects a clear type for elapsed time instead of overloading DateInterval, integers and timestamp arithmetic.

The accepted PHP RFC for a new Duration class is one of those changes that looks modest at first glance. It does not introduce a new programming paradigm, and it will probably not make release-note headlines like property hooks or fibers did.

But in day-to-day backend work, it addresses a problem we see regularly in Symfony and PHP projects: teams use the same constructs for two different concepts.

  • Calendar intervals: “one month from now”, “next business day”, “subscription renews yearly”.

  • Elapsed durations: “retry after 500 milliseconds”, “lock expires after 30 seconds”, “SLA budget is 2 minutes”.

PHP has long had DateInterval, but DateInterval is not a precise, context-free duration type. P1M is not a fixed number of seconds. Adding one month to January 31st is a calendar operation, not elapsed-time arithmetic. The new Duration class is important because it gives us a core language type for the second category: measurable amounts of time.

Why this matters in real projects

In Symfony applications, time values appear everywhere:

  • Messenger retry delays are expressed in milliseconds.

  • Cache TTLs are usually expressed in seconds.

  • HTTP client timeouts are often floats in seconds.

  • Lock TTLs, rate limits and job deadlines may use yet another convention.

  • Database columns named timeout, ttl or delay are frequently plain integers with no unit in the name.

That is how bugs enter mature systems. Not because developers do not understand time, but because the code does not force the distinction.

A typical example:

use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\DelayStamp;

$retryDelay = 15 * 60;

$cache->get('import-status-'.$importId, function (CacheItem $item) use ($retryDelay): ImportStatus {
    $item->expiresAfter($retryDelay);

    return ImportStatus::pending();
});

$bus->dispatch(new Envelope(
    new ImportMessage($importId),
    [new DelayStamp($retryDelay)]
));

This code is readable enough to pass review, but it is wrong: expiresAfter() expects seconds, while DelayStamp expects milliseconds. The same integer crosses two APIs with different unit conventions.

A dedicated duration type does not magically fix every API boundary. But it changes the design pressure: you stop passing “some integer” and start passing “a duration that must be converted explicitly at the boundary”.

DateInterval should not disappear — it should be used more deliberately

The wrong conclusion would be: “Once PHP has Duration, we should stop using DateInterval.”

DateInterval remains the right tool for calendar-aware operations:

$renewalDate = $currentPeriodStart->add(new DateInterval('P1M'));

This means “one calendar month later”. The result depends on the date. That is exactly what you want for billing periods, subscription renewals or reporting windows.

For a timeout, however, DateInterval('P1M') is usually a smell. A timeout should normally be measurable without knowing the start date, the timezone or the calendar month. That is the conceptual space where PHP 8.6’s Duration belongs.

A useful rule for code reviews:

If the value answers “how long did this take?” or “how long should we wait?”, model it as a duration. If it answers “what calendar date comes next?”, model it as a calendar interval.

Prepare your codebase before PHP 8.6

You do not need to wait for PHP 8.6 to improve your design. In fact, the safest migration path is to introduce explicit duration boundaries now and later adapt their implementation to the core class.

A small project-level value object already removes most ambiguity:

namespace App\Time;

use InvalidArgumentException;

final readonly class TimeSpan
{
    private function __construct(private int $milliseconds)
    {
        if ($milliseconds < 0) {
            throw new InvalidArgumentException('A time span cannot be negative.');
        }
    }

    public static function milliseconds(int $milliseconds): self
    {
        return new self($milliseconds);
    }

    public static function seconds(int $seconds): self
    {
        return new self($seconds * 1_000);
    }

    public static function minutes(int $minutes): self
    {
        return self::seconds($minutes * 60);
    }

    public function toMilliseconds(): int
    {
        return $this->milliseconds;
    }

    public function toWholeSeconds(): int
    {
        if ($this->milliseconds % 1_000 !== 0) {
            throw new InvalidArgumentException('The time span cannot be represented as whole seconds.');
        }

        return intdiv($this->milliseconds, 1_000);
    }
}

Then make unit conversion visible at framework boundaries:

use App\Time\TimeSpan;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\DelayStamp;

$retryDelay = TimeSpan::minutes(15);

$cache->get('import-status-'.$importId, function (CacheItem $item) use ($retryDelay): ImportStatus {
    $item->expiresAfter($retryDelay->toWholeSeconds());

    return ImportStatus::pending();
});

$bus->dispatch(new Envelope(
    new ImportMessage($importId),
    [new DelayStamp($retryDelay->toMilliseconds())]
));

This is intentionally not clever. The benefit is that the conversion is now explicit and reviewable. If someone changes the delay to TimeSpan::milliseconds(500), the cache boundary will fail fast instead of silently rounding down to zero seconds.

Once your minimum runtime is PHP 8.6, this value object can either wrap the native Duration class or disappear where framework APIs support the native type directly. The important part is that your domain and application services no longer depend on unlabelled integers.

Store durations with the unit in the model, not only in the database schema

Another common anti-pattern is a database column named duration or ttl with an INT type and the unit documented only in a migration comment. Six months later, someone reads the entity property and has to guess.

Prefer naming the unit where the value enters persistence:

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
final class ImportJob
{
    #[ORM\Column(type: 'integer')]
    private int $processingBudgetMilliseconds;

    public function setProcessingBudget(TimeSpan $budget): void
    {
        $this->processingBudgetMilliseconds = $budget->toMilliseconds();
    }

    public function processingBudget(): TimeSpan
    {
        return TimeSpan::milliseconds($this->processingBudgetMilliseconds);
    }
}

This keeps Doctrine mapping simple and portable while preventing the rest of the application from treating the value as an arbitrary integer. It also gives you one obvious migration point if you later decide to store microseconds, nanoseconds or a native database interval type.

Do not use wall-clock time to measure durations

The new Duration class also makes another distinction more visible: measuring elapsed time is not the same as reading the current date.

For performance measurements, use a monotonic clock source such as hrtime() rather than subtracting two DateTimeImmutable instances created from wall-clock time. Wall-clock time can change because of NTP adjustments, manual clock changes or timezone-related assumptions. A monotonic timer is designed for elapsed measurements.

$startedAt = hrtime(true);

$processor->process($message);

$elapsedNanoseconds = hrtime(true) - $startedAt;
$elapsed = TimeSpan::milliseconds(intdiv($elapsedNanoseconds, 1_000_000));

In Symfony applications, the Clock component is also worth considering for services that need testable time access. It helps avoid hard-coded calls to new DateTimeImmutable() in business logic and makes time-dependent tests deterministic.

What we expect to change in Symfony and PHP code style

The most useful consequence of PHP 8.6’s Duration class will not be shorter code. It will be better interfaces.

Today, many application services look like this:

final readonly class ImportScheduler
{
    public function schedule(string $importId, int $delay): void
    {
        // Is $delay seconds, milliseconds, minutes?
    }
}

A better interface says what it means:

use App\Time\TimeSpan;

final readonly class ImportScheduler
{
    public function schedule(string $importId, TimeSpan $delay): void
    {
        // The unit is no longer part of the caller's guesswork.
    }
}

With PHP 8.6, the native class gives library and framework authors a common target. That is where this RFC becomes strategically interesting: once the ecosystem can type against a shared duration concept, fewer APIs need to invent their own integer conventions.

Our recommendation for existing projects is therefore pragmatic:

  1. Keep using DateInterval for calendar logic.

  2. Stop passing raw integers for timeouts, TTLs and delays inside your application layer.

  3. Convert to framework-specific units only at the boundary.

  4. Use monotonic time for measurements.

  5. Plan a small compatibility layer so PHP 8.6’s Duration can be adopted without rewriting business logic.

That is not a cosmetic refactoring. It removes a class of production bugs that otherwise tends to surface only under load, during retries, in queues or around time-sensitive integrations.

Sources

Plan your PHP 8.6 migration

SensioLabs helps teams modernize Symfony and PHP codebases with pragmatic architecture reviews, migration planning and hands-on support.

This might also interest you

Abstract editorial illustration of PHP 8.6 JSON parsing with a highlighted error position and subtle purple accents
Oskar Stark

PHP 8.6: JSON Decode Error Position

PHP 8.6 adds a small but useful improvement to JSON decoding: you can now get the exact error position when parsing fails. That makes debugging malformed payloads much faster.

Read more : PHP 8.6: JSON Decode Error Position
Modern abstract editorial scene with cleaner, safer sorting API concepts and subtle purple accents
Oskar Stark

PHP 8.6 SortDirection: cleaner, safer sorting APIs for modern PHP

PHP 8.6 introduces SortDirection, a small but useful step toward clearer sorting APIs. Here’s what it changes, why it matters, and how to use it in practice.

Read more : PHP 8.6 SortDirection: cleaner, safer sorting APIs for modern PHP
Large tree under the sunlight
Mathieu Santostefano

Multiply your AI development speed using Git Worktrees

Say goodbye to context switching and the "stash-and-switch" headache. By leveraging Git Worktrees alongside modern AI agents, you can now isolate environments and handle bug fixes in parallel while your AI builds features in the background. It’s a total DX game-changer that turns your Git workflow into a multi-threaded powerhouse.

Read more : Multiply your AI development speed using Git Worktrees
A man sculpting a rock with PDF written on it
Steven Renaux

Create a Custom Builder - A GotenbergBundle Story

In a previous article, we explored how to generate your first PDF in a few lines of code using Gotenberg and GotenbergBundle, a Symfony bundle that wraps Gotenberg's HTTP API to convert HTML or Office files into PDFs. That was a great start. But what happens when your application needs to generate multiple different PDFs, each with its own layout, styles, data?

Read more : Create a Custom Builder - A GotenbergBundle Story
Nicolas Grekas standing on stage at SymfonyLive Paris 2026
Jules Daunay

SymfonyLive Paris 2026: AI Revolution and a Peak Reunion for Team SensioLabs

The final curtain has fallen on SymfonyLive Paris 2026, and we're still buzzing ✨ (and seeing lines of code). As Symfony's creator and a long-time core sponsor, SensioLabs couldn't have picked a better moment to celebrate open source, innovation, and, most importantly, the amazing community that supports us.

Read more : SymfonyLive Paris 2026: AI Revolution and a Peak Reunion for Team SensioLabs
Paper notes on a wall
Imen Ezzine

Behind the Scenes: 3 Collaborative Ceremonies for Better Development

Following a recent LinkedIn post, I wanted to write this article to describe 3 ceremonies that truly made an impact on me during one of my latest missions: Event Storming, Example Mapping, and Domain Storytelling.

Read more : Behind the Scenes: 3 Collaborative Ceremonies for Better Development
Illustration of Developer
Silas Joisten

The Developer Experience Revolution 2026

Discover why Developer Experience matters more than ever and how better tools, smarter workflows, and a culture of learning can transform the way teams build software.

Read more : The Developer Experience Revolution 2026
Nicolas Grekas with a mic in his right hand raising his left hand on stage at SymfonyCon Amsterdam 2025
Jules Daunay

Symfony 8: Stability, Security, and Innovation for Developers

To celebrate the launch of Symfony 8, we sat down with Nicolas Grekas, an emblematic figure in open-source and a major contributor to the framework. Between new JSON components, security hardening, and native integration with PHP 8.4, Nicolas explains why version 8 is a natural continuation of previous Symfony versions, without disrupting businesses. Read on for an overview to help you understand what's new and approach your upgrade with confidence.

Read more : Symfony 8: Stability, Security, and Innovation for Developers