Skip to content

How to handle timezones

PHP's timezone handling has several silent failure modes. This guide covers the patterns and pitfalls encountered in real NENE2 field trials.

Always specify the timezone when creating DateTimeImmutable

new DateTimeImmutable('now') uses the server's date.timezone ini setting, which differs between environments. Always pass UTC explicitly for server-side timestamps:

php
// Fragile — depends on server's date.timezone
$now = new \DateTimeImmutable('now');

// Correct — always UTC
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));

For stored timestamps, format as ISO8601 UTC:

php
$now->format('Y-m-d\TH:i:s\Z') // → "2026-05-20T15:00:00Z"

Validate IANA timezone identifiers explicitly

PHP's DateTimeZone constructor accepts timezone abbreviations like "EST" without throwing an exception, but they are not canonical IANA identifiers. "America/New_York" is the correct IANA form.

php
// This succeeds — but "EST" is not an IANA identifier
$tz = new \DateTimeZone('EST'); // no exception!

// Correct validation:
try {
    $tz = new \DateTimeZone($input);
} catch (\Exception) {
    throw new InvalidTimezoneException("Unknown timezone: $input");
}

// Additional membership check for non-IANA abbreviations:
if (!in_array($input, \DateTimeZone::listIdentifiers(), true)) {
    throw new InvalidTimezoneException("Unknown timezone: $input");
}

Without the listIdentifiers() check, "EST", "PST", and similar abbreviations pass silently.

Parse local datetime strings with createFromFormat

When accepting a local datetime from user input (without timezone offset), use createFromFormat with the explicit format and timezone:

php
$tz    = new \DateTimeZone('Asia/Tokyo');
$local = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', '2026-06-01T10:00:00', $tz);

if ($local === false) {
    // Invalid format — '2026/06/01 10:00', '2026-06-01', etc. all return false
    throw new \InvalidArgumentException('Invalid datetime format. Expected YYYY-MM-DDTHH:mm:ss.');
}

Prefer createFromFormat over new DateTimeImmutable($str, $tz) — the constructor is lenient and accepts many formats silently.

Convert local time to UTC for storage

php
$utc = $local->setTimezone(new \DateTimeZone('UTC'));
// Store as: $utc->format('Y-m-d\TH:i:s\Z')

Always store UTC in the database. Store the original timezone name alongside it so you can reconstruct the local time on retrieval.

DST transition: ambiguous wall-clock times

During "fall back" transitions (e.g., America/New_York on the first Sunday of November), some wall-clock times occur twice:

  • 2026-11-01 01:30 AM exists in both EDT (UTC-4) and EST (UTC-5)

PHP resolves the ambiguity by choosing the first occurrence (summer/DST time):

php
$dt = \DateTimeImmutable::createFromFormat(
    'Y-m-d\TH:i:s',
    '2026-11-01T01:30:00',
    new \DateTimeZone('America/New_York'),
);
// → 05:30 UTC (EDT = UTC-4), not 06:30 UTC (EST = UTC-5)

This matches the IANA standard. If your application needs to distinguish the two occurrences (e.g., for calendar systems), you must handle this at the application level — PHP does not expose an API for selecting the second occurrence.

Complete local→UTC conversion pattern

php
use Schedule\Event\InvalidTimezoneException;

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

    // Reject non-IANA abbreviations (e.g. "EST")
    if (!in_array($ianaTimezone, \DateTimeZone::listIdentifiers(), 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'));
}

Multi-timezone list queries

When listing events stored in UTC, convert to a viewer's timezone on the way out:

php
$viewTz = QueryStringParser::string($request, 'timezone');

if ($viewTz !== null) {
    try {
        $tz    = new \DateTimeZone($viewTz);
        $local = (new \DateTimeImmutable($event->startUtc, new \DateTimeZone('UTC')))->setTimezone($tz);
        $data['start_local'] = $local->format('Y-m-d\TH:i:s');
    } catch (\Exception) {
        // Invalid requested timezone — silently omit the conversion
    }
}

SQLite-specific: datetime('now') always returns UTC

SQLite's built-in date/time functions always operate in UTC, regardless of the server's OS timezone or PHP's date.timezone setting.

sql
SELECT datetime('now');          -- → "2026-05-27 11:30:00"  (UTC)
SELECT date('now');              -- → "2026-05-27"            (UTC date)
SELECT date('now', '+1 day');    -- → "2026-05-28"            (UTC + 1 day)
SELECT datetime('now', '-9 hours'); -- → local JST approximate (manual offset — avoid this)

This is usually what you want: store timestamps as UTC TEXT and compare in UTC.

Filtering by "today" in UTC

sql
-- Records created today (UTC)
SELECT * FROM events WHERE DATE(created_at) = DATE('now');

-- Records in the next 30 days (UTC)
SELECT * FROM reminders WHERE reminder_at <= DATE('now', '+30 days');

-- Records for a specific month (UTC)
SELECT * FROM logs WHERE STRFTIME('%Y-%m', created_at) = '2026-05';

The trap: "today" differs by timezone

If your users are in JST (UTC+9), "today" in JST starts 9 hours before "today" in UTC. DATE('now') in SQLite returns the UTC date — this is a mismatch.

php
// Wrong: SQLite DATE('now') = UTC date, not user's local date
$rows = $this->db->fetchAll("SELECT * FROM tasks WHERE DATE(due_date) = DATE('now')");

// Correct: compute the user's "today" in PHP and pass it as a parameter
$todayUtc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d');
$rows = $this->db->fetchAll(
    "SELECT * FROM tasks WHERE DATE(due_date) = ?",
    [$todayUtc],
);

For a service where "today" means UTC, DATE('now') is fine. For user-facing "due today" features, compute the boundary in PHP using the user's timezone and pass it as a bind parameter.

Dynamic interval with a column value

SQLite allows combining date() with a string built from a column value:

sql
-- Records where next_review_at = today based on interval_days column
SELECT * FROM cards WHERE next_review_at <= DATE('now');

-- Computing next review date dynamically (store result, don't rely on this in SELECT)
SELECT DATE('now', '+' || interval_days || ' days') AS next_date FROM cards;

This is useful in a UPDATE statement when advancing a schedule:

php
$this->db->execute(
    "UPDATE cards SET next_review_at = DATE('now', '+' || interval_days || ' days') WHERE id = ?",
    [$cardId],
);

STRFTIME format reference

PatternOutputUse
%Y-%m-%d2026-05-27Full date
%Y-%m2026-05Year-month grouping
%Y-%W2026-22Year + Sunday-starting week number (0–53)
%H:%M:%S11:30:00Time only
%sUnix timestampInteger seconds since epoch

%W is Sunday-starting, not ISO 8601 (Monday-starting). For Monday-starting week numbers, compute the week boundary in PHP:

php
// Get the Monday of the current ISO week
$monday = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
    ->modify('Monday this week')
    ->format('Y-m-d');

$sunday = (new \DateTimeImmutable($monday))->modify('+6 days')->format('Y-m-d');

$rows = $this->db->fetchAll(
    "SELECT * FROM workouts WHERE workout_date BETWEEN ? AND ?",
    [$monday, $sunday],
);

Released under the MIT License.