Aller au contenu
Sylvain Joffraud l4w
Retour

Parser une date FR en PHP : le `!` qui force minuit

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

#php #date #validation #dry #domain-logic


Partager cet article :

Article précédent
Migrer un serveur de prod en 1 journée : downtime < 10 min
Article suivant
Magic strings de session : un SessionManager typé