PHPTimezones

Timezones

A timestamp on its own — a plain number of seconds since 1970 — has no timezone, but the moment you format it as a human-readable date and time, a timezone has to be chosen to interpret it against. Get that choice wrong, or let it vary unexpectedly between a server and the people using an application, and dates start appearing hours off from where they should be. This is one of the most common and most avoidable classes of bugs in web applications.

PHP's default timezone

If no timezone is set anywhere, PHP falls back to a default defined by the date.timezone setting in php.ini (or UTC if that's also unset, as of PHP 5.4+, though older configurations may still warn about it). Relying on the php.ini default is risky because it silently varies between local development machines, staging servers, and production hosts. date_default_timezone_set() lets a script pin the timezone explicitly at runtime, independent of whatever the server happens to be configured with.

Setting the default timezone explicitly

PHP
<?php
date_default_timezone_set('UTC');

echo date('Y-m-d H:i:s'), "\n";
echo date_default_timezone_get(), "\n";
2025-06-15 09:00:00
UTC

This call should happen once, early, near the top of an application's bootstrap — not scattered across individual scripts — so every part of the codebase agrees on the same baseline.

DateTimeZone: attaching a specific zone to a date

Rather than relying on the process-wide default, the object-oriented date API lets a specific DateTimeZone be attached to an individual DateTime/DateTimeImmutable instance. This is the better approach whenever an application deals with more than one timezone at once — for example, showing each user a timestamp in their own local time.

Creating a date in a specific timezone

PHP
<?php
$utc = new DateTimeZone('UTC');
$tokyo = new DateTimeZone('Asia/Tokyo');

$meeting = new DateTimeImmutable('2025-06-15 09:00:00', $utc);
echo $meeting->format('Y-m-d H:i:s T'), "\n";

$inTokyo = $meeting->setTimezone($tokyo);
echo $inTokyo->format('Y-m-d H:i:s T'), "\n";
2025-06-15 09:00:00 UTC
2025-06-15 18:00:00 JST

Both variables represent the exact same instant in time — -> setTimezone() doesn't shift "when" the moment is, it only changes which local clock reading is used to display it. Timezone names like Asia/Tokyo and America/New_York come from the IANA timezone database, which is preferable to fixed offsets like +09:00 because it automatically accounts for daylight saving rules that fixed offsets can't express.

Store in UTC, convert only for display

The pattern that avoids the most bugs is simple to state: persist every date and time in a database as UTC, do all internal comparisons and calculations in UTC, and convert to a specific user's local timezone only at the very last moment, when rendering something for a human to read.

Store in UTC, display in the user's zone

PHP
<?php
// Value coming out of the database, already UTC
$storedUtc = new DateTimeImmutable('2025-03-10 02:30:00', new DateTimeZone('UTC'));

function displayForUser(DateTimeImmutable $utcDate, string $userTimezone): string
{
    $local = $utcDate->setTimezone(new DateTimeZone($userTimezone));
    return $local->format('F j, Y \a\t g:i A T');
}

echo displayForUser($storedUtc, 'America/New_York'), "\n";
echo displayForUser($storedUtc, 'Europe/London'), "\n";
March 9, 2025 at 9:30 PM EST
March 10, 2025 at 2:30 AM UTC

Notice the New York reading even lands on a different calendar day than the UTC value — this is exactly the kind of detail that gets lost when timezone handling is bolted on as an afterthought instead of designed in from the start.

Daylight saving time and ambiguous local times

Twice a year, in timezones that observe daylight saving time, a local clock either skips an hour or repeats one. That repeated hour creates a genuinely ambiguous local time — "2:30 AM" during a fall-back transition happened twice, and without extra context there's no way to know which occurrence was meant. This is precisely why storing everything in UTC matters: UTC has no daylight saving rules, so a UTC timestamp is always unambiguous, and the ambiguity only ever appears at the display layer, where it's usually harmless.

Server timezone and application timezone can silently disagree
A very common production bug: a server is provisioned with its system clock and default timezone set to UTC, but the PHP application (or a specific script run via cron) never calls `date_default_timezone_set()` and ends up relying on whatever `php.ini` happens to say — which might be a different value left over from a base image or a previous configuration. The fix is to never depend on an implicit, ambient default: set the timezone explicitly in application bootstrap code, and pass an explicit `DateTimeZone` whenever constructing a date whose timezone matters.
  • date_default_timezone_set() sets the process-wide default; call it once, early, rather than relying on php.ini.

  • DateTimeZone attaches an explicit timezone to an individual date object, independent of the process default.

  • ->setTimezone() reinterprets the same instant for display in a different zone — it does not change what instant is represented.

  • IANA zone names (America/New_York) correctly track daylight saving rules; fixed offsets (+09:00) do not.

  • Store and compute in UTC; convert to a local zone only when displaying to a specific user.

Note
`timezone_identifiers_list()` returns every valid IANA timezone name PHP recognizes, which is useful for validating a value before passing it to `new DateTimeZone()` — an invalid name throws an `Exception` rather than failing silently.
Tip
When a user's timezone preference is known (from their profile or browser), store it as an IANA name string, not a raw UTC offset. An offset like `-05:00` is only correct for part of the year in zones that observe daylight saving time, while the zone name `America/ New_York` stays correct year-round because PHP's timezone database already knows the transition rules.