Skip to content

Comment valider les datetimes ISO 8601 avec fuseau horaire ​

Accepter des chaînes datetime contrôlées par l'utilisateur nécessite une validation soigneuse. Ce guide couvre les deux pièges les plus importants : PHP acceptant silencieusement des offsets de fuseau horaire invalides, et la comparaison de chaînes échouant entre différents offsets de fuseau horaire.


V::isoDatetime — Validation de format ​

php
V::isoDatetime(mixed $raw): ?string

Valide une chaîne datetime au format offset ±HH:MM :

✅ 2024-01-15T12:30:00+09:00   (JST)
✅ 2024-06-01T00:00:00+00:00   (UTC)
✅ 2024-12-31T23:59:59-05:00   (EST)
✅ 2026-06-15T09:00:00-14:00   (UTC−14, île Howland)
✅ 2026-06-15T09:00:00+14:00   (UTC+14, Kiribati)

❌ 2024-01-15                   (date seule, pas d'heure)
❌ 2024-01-15T12:00:00Z         (suffixe 'Z', pas ±HH:MM)
❌ 2024-01-15T12:00:00          (pas d'offset du tout)
❌ 2024-02-30T00:00:00+00:00   (30 février n'existe pas)
❌ 2024-13-01T00:00:00+00:00   (mois 13 n'existe pas)
❌ 2026-06-15T09:00:00+25:00   (offset invalide — dépasse +14:00)

Implémentation ​

php
public static function isoDatetime(mixed $raw): ?string
{
    if (!is_string($raw)) return null;

    // Regex strict : ±HH:MM requis, pas de Z, pas d'heure seule
    if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-])(\d{2}):(\d{2})$/', $raw, $m)) {
        return null;
    }

    // Valider la plage de l'offset : les offsets UTC valides sont −14:00 … +14:00.
    // DateTimeImmutable de PHP accepte silencieusement +25:00 et autres offsets invalides.
    $tzHours   = (int) $m[2];
    $tzMinutes = (int) $m[3];
    if ($tzHours > 14 || $tzMinutes > 59 || ($tzHours === 14 && $tzMinutes > 0)) {
        return null;
    }

    // DateTimeImmutable préserve le fuseau horaire d'entrée — évite strtotime + date()
    // qui reformate silencieusement dans le fuseau horaire local du serveur.
    $dt = DateTimeImmutable::createFromFormat(DATE_ATOM, $raw);
    if ($dt === false) return null;

    // La comparaison aller-retour capture les dates de débordement (30 fév → 1er mars, etc.)
    return $dt->format(DATE_ATOM) === $raw ? $raw : null;
}

Pourquoi pas strtotime + date() ? ​

php
// ❌ INCORRECT — date() utilise le fuseau horaire local du serveur
$ts = strtotime('2024-01-15T12:30:00+09:00');
$canonical = date('c', $ts);
// Si le serveur est UTC : '2024-01-15T03:30:00+00:00' — fuseau horaire perdu !
php
// ✅ CORRECT — DateTimeImmutable préserve l'offset original
$dt = DateTimeImmutable::createFromFormat(DATE_ATOM, '2024-01-15T12:30:00+09:00');
$dt->format(DATE_ATOM); // → '2024-01-15T12:30:00+09:00' ✓

V::futureDatetime — Vérification futur entre fuseaux horaires ​

php
V::futureDatetime(mixed $raw, string $now): ?string

Retourne la chaîne validée uniquement si la datetime est strictement après $now.

Le bug critique : La comparaison de chaînes échoue entre fuseaux horaires ​

php
$now  = '2026-06-01T10:00:00+00:00';  // UTC 10:00

// JST 18:00 = UTC 09:00 → 1 heure dans le PASSÉ
$pastJst = '2026-06-01T18:00:00+09:00';

// ❌ INCORRECT : la comparaison de chaînes dit futur ("T18" > "T10")
$pastJst > $now  // → TRUE   ← INCORRECT ! C'est dans le passé !

// ✅ CORRECT : La comparaison DateTimeImmutable normalise en UTC d'abord
$dtObj = new DateTimeImmutable('2026-06-01T18:00:00+09:00');  // UTC 09:00
$nowObj = new DateTimeImmutable('2026-06-01T10:00:00+00:00');  // UTC 10:00
$dtObj > $nowObj  // → FALSE ✓ (correctement dans le passé)

L'erreur inverse se produit aussi avec les offsets négatifs :

php
// EST 08:00 = UTC 13:00 → 3 heures dans le FUTUR
$futureEst = '2026-06-01T08:00:00-05:00';

// ❌ INCORRECT : la comparaison de chaînes dit passé ("T08" < "T10")
$futureEst > $now  // → FALSE  ← INCORRECT ! C'est dans le futur !

// ✅ CORRECT : comparaison d'objets
$dtObj = new DateTimeImmutable('2026-06-01T08:00:00-05:00');  // UTC 13:00
$dtObj > $nowObj  // → TRUE ✓ (correctement dans le futur)

Implémentation ​

php
public static function futureDatetime(mixed $raw, string $now): ?string
{
    $dt = self::isoDatetime($raw);
    if ($dt === null) return null;

    $dtObj  = DateTimeImmutable::createFromFormat(DATE_ATOM, $dt);
    $nowObj = DateTimeImmutable::createFromFormat(DATE_ATOM, $now);

    if ($dtObj === false || $nowObj === false) return null;

    // La comparaison d'objets normalise les deux en UTC avant de comparer.
    return $dtObj > $nowObj ? $dt : null;
}

Utilisation dans un gestionnaire de route ​

php
private function handleCreate(ServerRequestInterface $request): ResponseInterface
{
    // ...
    $rawRemindAt = $body['remind_at'] ?? null;

    if (!is_string($rawRemindAt)) {
        return $this->responseFactory->create(
            ['error' => 'remind_at is required (ISO 8601 with timezone, e.g. 2026-06-01T09:00:00+09:00).'],
            422,
        );
    }

    // Utiliser DateTimeImmutable pour un "now" préservant le fuseau horaire
    $now      = (new DateTimeImmutable())->format(DATE_ATOM);
    $remindAt = V::futureDatetime($rawRemindAt, $now);

    if ($remindAt === null) {
        return $this->responseFactory->create(
            ['error' => 'remind_at must be a valid ISO 8601 datetime with timezone and must be in the future.'],
            422,
        );
    }

    // $remindAt est maintenant sûr à stocker — la chaîne exacte soumise, fuseau horaire préservé.
    $reminder = $this->repository->create($userId, $message, $remindAt, $now);
    // ...
}

Préservation du fuseau horaire ​

Stocker remind_at (ou tout datetime soumis par l'utilisateur) exactement tel que validé — ne pas convertir en UTC.

php
// ✅ Stocker la chaîne validée telle quelle
'INSERT INTO reminders (remind_at, ...) VALUES (:remind_at, ...)'
// avec :remind_at = '2026-06-15T09:00:00+09:00'

// Le retourner inchangé dans la réponse API
$reminder->remindAt  // → '2026-06-15T09:00:00+09:00'

Cela respecte l'intention de l'utilisateur et évite la conversion implicite de fuseau horaire. Si votre application nécessite la normalisation UTC pour le tri/la comparaison en SQL, ajouter une colonne remind_at_utc séparée calculée au moment de l'écriture.


Entrées validées → SQL sécurisé ​

Après V::isoDatetime() / V::futureDatetime(), la chaîne est sûre à insérer via une requête paramétrée. Ne jamais interpoler des chaînes datetime brutes dans du SQL.

php
// ✅ Sûr — pré-validé, paramétré
$stmt->execute(['remind_at' => $remindAt]);

// ❌ Dangereux — entrée utilisateur brute interpolée
$sql = "INSERT INTO reminders (remind_at) VALUES ('{$_POST['remind_at']}')";

Références ​

  • FT181 — reminderlog : Validation Datetime ISO 8601 & API avec conscience du fuseau horaire
  • RFC 3339 — Date et Heure sur Internet
  • Base de données de fuseaux horaires IANA — Référence d'offset UTC
  • docs/howto/json-merge-patch.md — utilise aussi isoDatetime pour created_at

Publié sous licence MIT.