Table of contents
- Why this matters in real projects
- DateInterval should not disappear — it should be used more deliberately
- Prepare your codebase before PHP 8.6
- Store durations with the unit in the model, not only in the database schema
- Do not use wall-clock time to measure durations
- What we expect to change in Symfony and PHP code style
- Sources
PHP 8.6 Duration: a small class that fixes a recurring design mistake
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,ttlordelayare 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:
Keep using
DateIntervalfor calendar logic.Stop passing raw integers for timeouts, TTLs and delays inside your application layer.
Convert to framework-specific units only at the boundary.
Use monotonic time for measurements.
Plan a small compatibility layer so PHP 8.6’s
Durationcan 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.