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.