Skip to content

How-to : Planification d'événements avec gestion des fuseaux horaires

Référence FT : FT286 (NENE2-FT/schedulelog) — Planification avec fuseaux horaires : stockage UTC + conversion en heure locale, validation de timezone IANA via DateTimeZone::listIdentifiers(), InvalidTimezoneException, paramètre de requête dynamique ?timezone, 19 tests / 39 assertions PASS.

Ce guide montre comment construire une API de planification d'événements qui stocke les heures en UTC et les présente dans n'importe quel fuseau horaire demandé par le client.

Pourquoi stocker en UTC ?

UTC est le point de référence universel. Les heures locales sont ambiguës (changements d'heure, changements de règles de fuseau horaire) et varient selon l'emplacement du client. En stockant en UTC :

  • Le tri et la comparaison sont toujours corrects
  • Les clients peuvent afficher dans leur fuseau horaire local
  • Les transitions DST ne créent pas d'ambiguïté dans les données historiques

Schéma

sql
CREATE TABLE IF NOT EXISTS events (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    title       TEXT    NOT NULL,
    timezone    TEXT    NOT NULL,      -- Fuseau horaire IANA du créateur d'événement
    start_utc   TEXT    NOT NULL,      -- UTC ISO 8601 : 2026-05-20T15:00:00Z
    start_local TEXT    NOT NULL,      -- Local ISO 8601 : 2026-05-20T10:00:00
    created_at  TEXT    NOT NULL
);

start_utc et start_local sont tous deux stockés. start_utc fait autorité ; start_local est un cache de commodité pour le fuseau horaire du créateur.

Endpoints

MéthodeCheminDescription
POST/eventsCréer un événement (fuseau horaire + heure locale → UTC)
GET/eventsLister les événements (optionnel ?timezone=America/New_York)
GET/events/{id}Obtenir un événement (optionnel ?timezone=)

Validation de fuseau horaire IANA

Le constructeur DateTimeZone de PHP accepte silencieusement certains identifiants invalides. Valider explicitement :

php
final class TimezoneConverter
{
    public static function localToUtc(string $localDatetime, string $ianaTimezone): \DateTimeImmutable
    {
        try {
            $tz = new \DateTimeZone($ianaTimezone);
        } catch (\Exception) {
            throw new InvalidTimezoneException("Unknown timezone: $ianaTimezone");
        }

        // PHP accepte des abréviations invalides comme "EST" dans certaines versions —
        // valider explicitement par rapport à la liste IANA canonique.
        $valid = \DateTimeZone::listIdentifiers();
        if (!in_array($ianaTimezone, $valid, true)) {
            throw new InvalidTimezoneException("Unknown timezone: $ianaTimezone");
        }

        $local = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $localDatetime, $tz);

        if ($local === false) {
            throw new \InvalidArgumentException("Cannot parse datetime: $localDatetime");
        }

        return $local->setTimezone(new \DateTimeZone('UTC'));
    }
}

DateTimeZone::listIdentifiers() retourne la liste des identifiants IANA compilée par PHP. Les chaînes non-IANA (comme EST, GMT+5) sont rejetées.

Créer un événement : heure locale → UTC

php
try {
    $utc = TimezoneConverter::localToUtc($start, $timezone);
} catch (InvalidTimezoneException) {
    return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
        'errors' => [['field' => 'timezone', 'code' => 'invalid', 'message' => "Unknown timezone: $timezone"]],
    ]);
} catch (\InvalidArgumentException) {
    return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
        'errors' => [['field' => 'start', 'code' => 'invalid', 'message' => "Cannot parse datetime: $start"]],
    ]);
}

$startUtc   = TimezoneConverter::formatUtc($utc);                              // "2026-05-20T15:00:00Z"
$startLocal = TimezoneConverter::formatLocal($utc->setTimezone(new \DateTimeZone($timezone)));  // "2026-05-20T10:00:00"

Lister les événements : conversion dynamique de fuseau horaire

Le paramètre de requête ?timezone= convertit tous les événements dans le fuseau horaire du client à la volée :

php
$viewTz = isset($params['timezone']) && $params['timezone'] !== '' ? $params['timezone'] : null;

$items = array_map(static function (Event $e) use ($viewTz): array {
    $data = $e->toArray();
    if ($viewTz !== null) {
        try {
            $local = TimezoneConverter::utcToLocal($e->startUtc, $viewTz);
            $data['start_local'] = TimezoneConverter::formatLocal($local);
            $data['view_timezone'] = $viewTz;
        } catch (InvalidTimezoneException) {
            // Fuseau horaire de vue invalide : retourner silencieusement en UTC
            $data['view_timezone'] = 'UTC';
        }
    }
    return $data;
}, $events);

Les valeurs ?timezone= invalides retombent silencieusement sur le start_local stocké plutôt que de retourner une erreur — un choix de conception approprié pour les vues en lecture seule.

Format UTC : ISO 8601 avec suffixe Z

php
public static function formatUtc(\DateTimeImmutable $dt): string
{
    return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
    //                                                                           ^ Z littéral
}

Le suffixe Z indique explicitement UTC (selon ISO 8601 / RFC 3339). Utiliser +00:00 ou omettre le décalage sont des alternatives acceptables, mais Z est plus compact et universellement reconnu.

Conversion sécurisée pour le passage à l'heure d'été (DST)

Exemple : Asia/Tokyo est UTC+9 (pas de changement d'heure)
Local : 2026-05-20T10:00:00  Asia/Tokyo
UTC :   2026-05-20T01:00:00Z

Exemple : America/New_York (avec changement d'heure)
Local : 2026-05-20T10:00:00  America/New_York (EDT = UTC-4 en été)
UTC :   2026-05-20T14:00:00Z

Local : 2026-01-20T10:00:00  America/New_York (EST = UTC-5 en hiver)
UTC :   2026-01-20T15:00:00Z

DateTimeImmutable avec un fuseau horaire IANA nommé gère automatiquement le changement d'heure. Il utilise le décalage actif à cette date spécifique, pas un décalage fixe.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Stocker l'heure locale sans colonne de fuseau horaireImpossible de convertir en UTC plus tard ; les données historiques deviennent ambiguës après les changements d'heure
Accepter EST, PST, GMT+5 comme fuseau horaireAbréviations ambiguës ; certaines correspondent à plusieurs zones IANA ; DateTimeZone::listIdentifiers() les rejette
Utiliser new DateTimeZone($tz) sans vérifier listIdentifiers()PHP accepte silencieusement certains identifiants invalides ou dépréciés ; la validation canonique les détecte
Stocker le décalage UTC (+09:00) au lieu du nom IANALe décalage seul ne peut pas gérer le changement d'heure ; Asia/Tokyo est toujours +9 mais America/New_York varie
Trier les événements par start_localLe tri lexicographique sur les heures locales ignore les différences de fuseau horaire ; toujours trier par start_utc
Convertir le fuseau horaire à chaque requêteCoûteux pour les grands datasets ; envisager la mise en cache ou le précalcul des fuseaux horaires de vue courants
Retourner 422 pour ?timezone= invalide dans GETLes requêtes en lecture seule doivent se dégrader gracieusement ; revenir en UTC plutôt qu'en erreur
Utiliser date() au lieu de DateTimeImmutabledate() utilise le fuseau horaire par défaut du serveur ; DateTimeImmutable avec des zones explicites est prévisible

Publié sous licence MIT.