Un audit a repéré huit DateTimeImmutable::createFromFormat('d/m/Y', ...) éparpillés dans le projet : formulaires, services de tarification, validateurs. Chacun avec sa petite variante de nettoyage, et chacun avec les mêmes deux bugs latents que createFromFormat tend silencieusement. En les centralisant dans un parser unique, j’ai pu corriger les deux pièges en un seul endroit — plus un troisième bug, purement métier, sur le calcul d’âge.
Piège n°1 — l’heure courante qui pollue les comparaisons
$date = \DateTimeImmutable::createFromFormat('d/m/Y', '20/11/2025');
// → 2025-11-20 16:48:33 ⚠️ l'HEURE COURANTE, pas minuit !
createFromFormat ne remplit que les composants présents dans le format. Comme d/m/Y ne dit rien de l’heure, PHP la complète avec… l’heure courante. Conséquence : deux dates « identiques » parsées à deux instants différents ne sont pas égales, et $date >= $aujourdhui devient instable selon l’heure d’exécution. Bug classique, sournois, qui ne casse que « parfois ».
Le correctif tient en un caractère : le modificateur !, qui réinitialise tous les champs non spécifiés à l’époque Unix (donc l’heure à 00:00:00) :
$date = \DateTimeImmutable::createFromFormat('!d/m/Y', '20/11/2025');
// → 2025-11-20 00:00:00 ✓ minuit, déterministe
Piège n°2 — les dates impossibles « absorbées » silencieusement
$date = \DateTimeImmutable::createFromFormat('!d/m/Y', '31/02/2025');
// → 2025-03-03 ⚠️ AUCUNE erreur. Le 31 février "déborde" sur mars.
createFromFormat ne renvoie false que si la chaîne est syntaxiquement invalide. Une date syntaxiquement correcte mais calendairement impossible (31 février, 30 février, 31 avril…) est silencieusement « débordée » sur le mois suivant. L’utilisateur saisit le 31/02, le système enregistre le 03/03, personne ne s’en aperçoit.
La parade : un roundtrip. On reformate la date obtenue et on vérifie qu’elle est identique à l’entrée nettoyée. Si PHP a débordé, le reformatage ne correspondra pas :
if ($date->format('d/m/Y') !== $cleaned) {
return null; // 31/02 → reformaté en 03/03 ≠ "31/02" → rejeté
}
Le parser centralisé
Les deux corrections, plus la tolérance aux espaces et un contrat sans exception, dans une fonction pure et statique :
final class FrenchDateParser
{
public const FORMAT = 'd/m/Y';
public static function tryParse(mixed $value): ?\DateTimeImmutable
{
if (!is_string($value)) {
return null;
}
$cleaned = str_replace(' ', '', $value); // "01 / 02 / 2025" → "01/02/2025"
if ($cleaned === '') {
return null;
}
// '!' force l'heure à 00:00:00 (sinon : heure courante → comparaisons instables)
$date = \DateTimeImmutable::createFromFormat('!' . self::FORMAT, $cleaned);
if ($date === false) {
return null;
}
// Roundtrip : rejette 31/02/2025 (sinon absorbé silencieusement vers 03/03/2025)
if ($date->format(self::FORMAT) !== $cleaned) {
return null;
}
return $date;
}
}
Deux choix assumés : statique (c’est une fonction pure, sans IO ni état — l’injecter dans un validateur ou un helper Twig serait du cérémonial pour rien), et tryParse qui renvoie null plutôt que de jeter (l’appelant décide quoi faire d’une date invalide : flash, violation de contrainte, valeur par défaut). Les huit createFromFormat dispersés appellent désormais tous FrenchDateParser::tryParse() — une seule définition du « comment on parse une date FR ».
Le bug métier débusqué au passage : l’âge au 1er janvier
En centralisant le parsing, je suis tombé sur un calcul d’âge incohérent dans la tarification. La règle métier (assurance) : l’âge est calculé au 1er janvier de l’année en cours, pas à la date du jour. Le code calculait l’âge « aujourd’hui » :
// ❌ âge "à aujourd'hui" : une personne change de tranche tarifaire le jour de son anniversaire
$age = (new \DateTimeImmutable())->diff($dateNaissance)->y;
Deux personnes nées la même année tombaient dans des tranches d’âge différentes selon qu’on était avant ou après leur anniversaire — et le tarif changeait en cours d’année. Le correctif applique la règle telle qu’elle est spécifiée :
// ✓ âge au 1er janvier de l'année courante (référence stable toute l'année)
$premierJanvier = new \DateTimeImmutable('first day of January this year');
$age = $premierJanvier->diff($dateNaissance)->y;
diff()->y donne le nombre d’années complètes entre les deux dates — exactement l’âge révolu à la date de référence. Avec le 1er janvier comme pivot, la tranche d’âge (et donc le tarif) est stable sur toute l’année civile, conformément à la spec.
C’est un effet de bord vertueux de la centralisation : en rassemblant la logique de date, on tombe sur les endroits où elle était appliquée de travers.
Lessons learned
createFromFormatcomplète les champs absents avec l’heure courante. Sur un formatd/m/Y(sans heure), la date obtenue porte l’heure d’exécution → comparaisons instables. Le modificateur!réinitialise tout à minuit. Mettez-le systématiquement quand vous ne parsez qu’une date.createFromFormatabsorbe silencieusement les dates impossibles.31/02devient03/03sans erreur. Faites un roundtrip ($d->format($fmt) === $entrée) pour rejeter ce que PHP a débordé.- Centralisez le parsing de date dans une fonction pure. Huit
createFromFormatéparpillés = huit occasions de réintroduire les deux pièges ci-dessus. UnetryParse()unique les corrige partout d’un coup. - Préférez
tryParse(): ?DateTimeImmutableà une version qui jette. Le parser ne devrait pas décider du sort d’une date invalide ; il renvoienull, l’appelant choisit (flash, contrainte, défaut). - « Âge » est une notion métier, pas
now()->diff(). Selon le domaine, l’âge se calcule à une date de référence précise (1er janvier, date d’effet du contrat…). Calculer « à aujourd’hui » fait basculer les gens de tranche le jour de leur anniversaire — rarement ce que veut la spec. - Centraliser une logique transverse révèle où elle était mal appliquée. En rassemblant le parsing de date, on tombe naturellement sur les calculs (comme l’âge) qui s’en servaient de travers.
#php #date #validation #dry #domain-logic