Table des matières
- Pourquoi c’est important concrètement dans les projets
- DateInterval ne doit pas disparaître, il doit être utilisé de manière plus délibérée
- Préparez votre base de code avant PHP 8.6
- Stockez les durées avec leur unité dans le modèle
- N’utilisez pas l’heure murale pour mesurer des durées
- Les conséquences pour le style de code Symfony et PHP
- Sources
PHP 8.6 Duration : une petite classe pour corriger une erreur récurrente
PHP 8.6 ajoute une classe Duration dédiée. En apparence, cette nouveauté peut sembler mineure, mais elle permettra enfin d'avoir un type de données clair pour le temps écoulé, plutôt que de surcharger la classe DateInterval, les entiers et l’arithmétique sur les timestamps.
La RFC PHP acceptée pour la nouvelle classe Duration est l’une de ces évolutions qui paraissent modestes au premier abord. Elle n’introduit pas de nouveau paradigme de programmation et ne fera probablement pas la une des notes de version, au même titre que les property hooks ou les fibers.
Mais, dans le travail du backend au quotidien, elle répond à un problème fréquent dans les projets Symfony et PHP : les équipes utilisent les mêmes structures pour 2 notions différentes.
Les intervalles calendaires : « dans un mois », « le prochain jour ouvré », « renouvellement annuel de l’abonnement ».
Les durées écoulées : « réessayer après 500 millisecondes », « le verrou expire après 30 secondes », « le budget SLA est de 2 minutes ».
PHP dispose depuis longtemps de DateInterval, mais DateInterval n’est pas un type de durée précis et indépendant du contexte. P1M ne correspond pas à un nombre fixe de secondes. Ajouter un mois au 31 janvier est une opération calendaire, pas un calcul de temps écoulé. La nouvelle classe Duration est importante, car elle fournit au langage un type de base pour la deuxième catégorie : des quantités de temps mesurables.
Pourquoi c’est important concrètement dans les projets
Dans les applications Symfony, les valeurs de temps apparaissent partout :
Les délais de retry de Messenger sont exprimés en millisecondes.
Les TTL de cache sont généralement exprimés en secondes.
Les timeouts des clients HTTP sont souvent des secondes flottantes.
Les TTL de verrous, les limites de débit et les deadlines de jobs peuvent suivre encore une autre convention.
Les colonnes de base de données nommées
timeout,ttloudelaysont fréquemment de simples entiers, sans unité dans le nom.
C’est ainsi que les bugs s’introduisent dans des systèmes matures. Non pas parce que les développeurs ne comprennent pas la temporalité, mais parce que le code ne permet pas de faire la distinction.
Voici un exemple typique :
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)]
));
Ce code est suffisamment lisible pour être revu, mais il contient une erreur : expiresAfter() utilise des secondes, tandis que DelayStamp utilise des millisecondes. Le même entier traverse 2 API avec des conventions d’unité différentes.
Un type de durée dédié ne résout pas magiquement tous les problèmes de conversion entre les API. Il change toutefois la pression sur la conception : on cesse de passer d'« un entier quelconque » et on commence à passer à « une durée qui doit être convertie explicitement à la frontière ».
DateInterval ne doit pas disparaître, il doit être utilisé de manière plus délibérée
La mauvaise conclusion serait de se dire : « Une fois que PHP aura Duration, il faudra arrêter d’utiliser DateInterval. »
DateInterval reste l’outil adapté aux opérations sensibles au calendrier. Par exemple :
$renewalDate = $currentPeriodStart->add(new DateInterval('P1M'));
Ou autrement dit « un mois calendaire plus tard ». Le résultat dépend de la date. C’est exactement ce qu’il faut pour les périodes de facturation, les renouvellements d’abonnement ou les fenêtres de reporting.
Pour un timeout, en revanche, DateInterval('P1M') est généralement un signal d’alerte. Un timeout devrait normalement pouvoir être mesuré sans connaître la date de départ, le fuseau horaire ou le mois du calendrier. C’est dans cet espace conceptuel que se situe la classe Duration de PHP 8.6.
Voici une règle utile pour les revues de code :
Si la valeur répond à « combien de temps cela a-t-il pris ? » ou « combien de temps devons-nous attendre ? », modélisez-la comme une durée. Si elle répond à « quelle date du calendrier vient ensuite ? », modélisez-la comme un intervalle calendaire.
Préparez votre base de code avant PHP 8.6
Vous n’avez pas besoin d’attendre PHP 8.6 pour améliorer vos développements. En réalité, la voie pour se préparer la plus sûre consiste à introduire dès maintenant des frontières explicites pour les durées, puis à adapter plus tard leur implémentation à la classe native.
Un petit objet de valeur au niveau du projet supprime déjà la plupart des ambiguïtés :
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);
}
}
Rendez ensuite la conversion d’unité visible aux frontières du framework :
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())]
));
Ce n’est volontairement pas sophistiqué. L’avantage est que la conversion devient désormais explicite et vérifiable lors de la revue de code. Si quelqu’un change le délai en TimeSpan::milliseconds(500), la frontière du cache échouera immédiatement au lieu d'être arrondie à zéro seconde, sans qu'on s'en rende compte.
Une fois que la version minimale de votre runtime est PHP 8.6, cet objet de valeur pourra soit encapsuler la classe native Duration, soit disparaître, car les API du framework prennent directement en charge le type natif. L'essentiel est que votre domaine et vos services applicatifs ne dépendent plus d’entiers sans libellé.
Stockez les durées avec leur unité dans le modèle
Un autre anti-pattern courant consiste à avoir une colonne de base de données nommée duration ou ttl avec un type INT, et l’unité documentée uniquement dans un commentaire de migration. Six mois plus tard, quelqu’un lit la propriété de l’entité et doit deviner l'unité.
Préférez plutôt nommer l’unité là où la valeur est stockée :
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);
}
}
Vous gardez ainsi un mapping Doctrine simple et portable, tout en empêchant le reste de l’application de traiter la valeur comme un entier arbitraire. Cela vous permet également d'identifier facilement un point de migration si vous décidez ultérieurement de stocker des microsecondes, des nanosecondes ou un type d'intervalle natif de la base de données.
N’utilisez pas l’heure murale pour mesurer des durées
La nouvelle classe Duration rend aussi plus visible une autre distinction : mesurer un temps écoulé n’est pas la même chose que lire la date courante.
Pour les mesures de performance, utilisez une source d’horloge monotone comme hrtime() plutôt que de soustraire deux instances de DateTimeImmutable créées à partir de l’heure murale. En effet, l’heure murale peut changer à cause des ajustements NTP, de modifications manuelles de l’horloge ou d’hypothèses liées au fuseau horaire. Un timer monotone est conçu pour les mesures de temps écoulé.
$startedAt = hrtime(true);
$processor->process($message);
$elapsedNanoseconds = hrtime(true) - $startedAt;
$elapsed = TimeSpan::milliseconds(intdiv($elapsedNanoseconds, 1_000_000));
Dans les applications Symfony, le composant Clock mérite aussi d’être envisagé pour les services qui ont besoin d’un accès testable au temps. Il aide à éviter les appels codés en dur à new DateTimeImmutable() dans la logique métier et rend les tests dépendants du temps déterministes.
Les conséquences pour le style de code Symfony et PHP
La conséquence la plus utile de la classe Duration de PHP 8.6 ne sera pas un code plus court, mais de meilleures interfaces.
Aujourd’hui, de nombreux services applicatifs ressemblent à ceci :
final readonly class ImportScheduler
{
public function schedule(string $importId, int $delay): void
{
// Is $delay seconds, milliseconds, minutes?
}
}
Une meilleure interface dit ce qu’elle signifie :
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.
}
}
Avec PHP 8.6, la classe native offre aux auteurs de bibliothèques et de frameworks une cible commune. C’est là que cette RFC devient stratégiquement intéressante : une fois que l’écosystème peut définir des types de données en fonction d'un concept de durée partagé, il y a moins de risques que les API inventent leurs propres conventions d’entiers..
Notre recommandation pour les projets existants est donc pragmatique :
Continuez à utiliser
DateIntervalpour la logique calendaire.Arrêtez de passer des entiers bruts pour les timeouts, TTL et délais dans votre couche applicative.
Convertissez aux unités spécifiques au framework uniquement à la frontière.
Utilisez du temps monotone pour les mesures.
Prévoyez une petite couche de compatibilité afin que
Durationde PHP 8.6 puisse être adoptée sans réécrire la logique métier.
Il ne s'agit pas d'un simple refactoring cosmétique. Il élimine une classe de bugs de production qui ne tendent à apparaître que sous charge, lors des retries, dans les queues ou autour d’intégrations sensibles au temps.