Skip to content

Anleitung: Zeitzonen-bewusste Ereignisplanung ​

FT-Referenz: FT286 (NENE2-FT/schedulelog) — Zeitzonen-bewusste Planung: UTC-Speicherung + Lokalzeit-Konvertierung, IANA-Zeitzonen-Validierung via DateTimeZone::listIdentifiers(), InvalidTimezoneException, dynamischer ?timezone-Abfrageparameter, 19 Tests / 39 Assertions BESTANDEN.

Diese Anleitung zeigt, wie eine Ereignisplanungs-API gebaut wird, die Zeiten in UTC speichert und sie in jeder Zeitzone darstellt, die der Client anfordert.

Warum in UTC speichern? ​

UTC ist der universelle Referenzpunkt. Lokalzeiten sind mehrdeutig (Sommerzeitwechsel, Zeitzonen-Regeländerungen) und variieren nach Client-Standort. Durch Speicherung in UTC:

  • Sortierung und Vergleich sind immer korrekt
  • Clients können in ihrer lokalen Zeitzone anzeigen
  • Sommerzeitübergänge schaffen keine Mehrdeutigkeit in historischen Daten

Schema ​

sql
CREATE TABLE IF NOT EXISTS events (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    title       TEXT    NOT NULL,
    timezone    TEXT    NOT NULL,      -- IANA-Zeitzone des Event-Erstellers
    start_utc   TEXT    NOT NULL,      -- UTC ISO 8601: 2026-05-20T15:00:00Z
    start_local TEXT    NOT NULL,      -- Lokal ISO 8601: 2026-05-20T10:00:00
    created_at  TEXT    NOT NULL
);

Sowohl start_utc als auch start_local werden gespeichert. start_utc ist maßgeblich; start_local ist ein Convenience-Cache für die Zeitzone des Erstellers.

Endpunkte ​

MethodePfadBeschreibung
POST/eventsEreignis erstellen (Zeitzone + lokale Startzeit → UTC)
GET/eventsEreignisse auflisten (optional ?timezone=America/New_York)
GET/events/{id}Ereignis abrufen (optional ?timezone=)

IANA-Zeitzonen-Validierung ​

PHPs DateTimeZone-Konstruktor akzeptiert einige ungültige Bezeichner stillschweigend. Explizit validieren:

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 akzeptiert in manchen Versionen ungültige Abkürzungen wie "EST" —
        // explizit gegen die kanonische IANA-Liste validieren.
        $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() gibt die PHP-kompilierte Liste von IANA-Bezeichnern zurück. Nicht-IANA-Strings (wie EST, GMT+5) werden abgelehnt.

Ereignis erstellen: Lokal → 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"

Ereignisse auflisten: Dynamische Zeitzonen-Konvertierung ​

Der ?timezone=-Abfrageparameter konvertiert alle Ereignisse sofort in die Zeitzone des Clients:

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) {
            // Ungültige Ansichts-Zeitzone: stillschweigend UTC zurückgeben
            $data['view_timezone'] = 'UTC';
        }
    }
    return $data;
}, $events);

Ungültige ?timezone=-Werte fallen stillschweigend auf das gespeicherte start_local zurück, statt einen Fehler zurückzugeben — eine Designentscheidung, die für schreibgeschützte Ansichten geeignet ist.

UTC-Format: ISO 8601 mit Z-Suffix ​

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

Das Z-Suffix zeigt explizit UTC an (gemäß ISO 8601 / RFC 3339). +00:00 zu verwenden oder den Offset wegzulassen sind akzeptable Alternativen, aber Z ist kompakter und universell erkannt.

DST-sichere Konvertierung ​

Beispiel: Asia/Tokyo ist UTC+9 (keine Sommerzeit)
Lokal: 2026-05-20T10:00:00  Asia/Tokyo
UTC:   2026-05-20T01:00:00Z

Beispiel: America/New_York (Sommerzeit)
Lokal: 2026-05-20T10:00:00  America/New_York (EDT = UTC-4 im Sommer)
UTC:   2026-05-20T14:00:00Z

Lokal: 2026-01-20T10:00:00  America/New_York (EST = UTC-5 im Winter)
UTC:   2026-01-20T15:00:00Z

DateTimeImmutable mit einer benannten IANA-Zeitzone behandelt Sommerzeit automatisch. Es verwendet den Offset, der an diesem bestimmten Datum aktiv ist, nicht einen festen Offset.


Was NICHT zu tun ist ​

Anti-MusterRisiko
Lokalzeit ohne Zeitzonen-Spalte speichernKann später nicht in UTC konvertiert werden; historische Daten werden nach Sommerzeitwechseln mehrdeutig
EST, PST, GMT+5 als Zeitzone akzeptierenMehrdeutige Abkürzungen; einige sind mehreren IANA-Zonen zugeordnet; DateTimeZone::listIdentifiers() lehnt diese ab
new DateTimeZone($tz) ohne Prüfung von listIdentifiers() verwendenPHP akzeptiert stillschweigend einige ungültige oder veraltete Bezeichner; kanonische Validierung erfasst sie
UTC-Offset (+09:00) statt IANA-Name speichernOffset allein kann Sommerzeit nicht behandeln; Asia/Tokyo ist immer +9, aber America/New_York variiert
Ereignisse nach start_local sortierenLexikografische Sortierung nach Lokalzeiten ignoriert Zeitzonenunterschiede; immer nach start_utc sortieren
Zeitzone bei jeder Abfrage konvertierenTeuer für große Datensätze; Caching oder Vorberechnung häufiger Ansichts-Zeitzonen in Betracht ziehen
422 für ungültiges ?timezone= in GET zurückgebenSchreibgeschützte Abfragen sollten gracefully degradieren; auf UTC zurückfallen statt Fehler
date() statt DateTimeImmutable verwendendate() verwendet die Standard-Zeitzone des Servers; DateTimeImmutable mit expliziten Zonen ist vorhersehbar

Veröffentlicht unter der MIT-Lizenz.