Skip to content

How-to: Timezone-aware Event Scheduling ​

FT reference: FT286 (NENE2-FT/schedulelog) — Timezone-aware scheduling: UTC storage + local time conversion, IANA timezone validation via DateTimeZone::listIdentifiers(), InvalidTimezoneException, dynamic ?timezone query parameter, 19 tests / 39 assertions PASS.

This guide shows how to build an event scheduling API that stores times in UTC and presents them in any timezone the client requests.

Why Store in UTC? ​

UTC is the universal reference point. Local times are ambiguous (DST changes, timezone rule changes) and vary by client location. By storing in UTC:

  • Sorting and comparison are always correct
  • Clients can display in their local timezone
  • DST transitions don't create ambiguity in historical data

Schema ​

sql
CREATE TABLE IF NOT EXISTS events (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    title       TEXT    NOT NULL,
    timezone    TEXT    NOT NULL,      -- IANA timezone of event creator
    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
);

Both start_utc and start_local are stored. start_utc is authoritative; start_local is a convenience cache for the creator's timezone.

Endpoints ​

MethodPathDescription
POST/eventsCreate event (timezone + local start → UTC)
GET/eventsList events (optional ?timezone=America/New_York)
GET/events/{id}Get event (optional ?timezone=)

IANA Timezone Validation ​

PHP's DateTimeZone constructor accepts some invalid identifiers silently. Validate explicitly:

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 accepts invalid abbreviations like "EST" in some versions —
        // validate against the canonical IANA list explicitly.
        $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() returns the PHP-compiled list of IANA identifiers. Non-IANA strings (like EST, GMT+5) are rejected.

Create Event: Local → 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"

List Events: Dynamic Timezone Conversion ​

The ?timezone= query parameter converts all events to the client's timezone on the fly:

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) {
            // Invalid view timezone: silently return UTC
            $data['view_timezone'] = 'UTC';
        }
    }
    return $data;
}, $events);

Invalid ?timezone= values silently fall back to the stored start_local rather than returning an error — a design choice appropriate for read-only views.

UTC Format: ISO 8601 with 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
}

The Z suffix explicitly indicates UTC (per ISO 8601 / RFC 3339). Using +00:00 or omitting the offset are acceptable alternatives, but Z is more compact and universally recognized.

DST-Safe Conversion ​

Example: Asia/Tokyo is UTC+9 (no DST)
Local: 2026-05-20T10:00:00  Asia/Tokyo
UTC:   2026-05-20T01:00:00Z

Example: America/New_York (DST)
Local: 2026-05-20T10:00:00  America/New_York (EDT = UTC-4 in summer)
UTC:   2026-05-20T14:00:00Z

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

DateTimeImmutable with a named IANA timezone automatically handles DST. It uses the offset active on that specific date, not a fixed offset.


What NOT to do ​

Anti-patternRisk
Store local time without timezone columnCannot convert to UTC later; historical data becomes ambiguous after DST changes
Accept EST, PST, GMT+5 as timezoneAmbiguous abbreviations; some map to multiple IANA zones; DateTimeZone::listIdentifiers() rejects these
Use new DateTimeZone($tz) without checking listIdentifiers()PHP silently accepts some invalid or deprecated identifiers; canonical validation catches them
Store UTC offset (+09:00) instead of IANA nameOffset alone cannot handle DST; Asia/Tokyo always +9 but America/New_York varies
Sort events by start_localLexicographic sort on local times ignores timezone differences; always sort by start_utc
Convert timezone on every queryExpensive for large datasets; consider caching or pre-computing common view timezones
Return 422 for invalid ?timezone= in GETRead-only queries should degrade gracefully; fall back to UTC rather than error
Use date() instead of DateTimeImmutabledate() uses server's default timezone; DateTimeImmutable with explicit zones is predictable

Released under the MIT License.