We had a booking system on PHP 8.1, running on a mix of app servers in UTC and a couple of legacy boxes still on Europe/London. A nightly report summed “bookings in the next 7 days” and the number was off by one or two rows around the clock-change weekends, twice a year, for about a year before anyone connected the dots. The table had ~400k rows, nothing huge, but the report window was computed with strtotime on a date string pulled from a user’s saved preference.
The code looked innocent:
$tz = new DateTimeZone($user->timezone); // e.g. "Europe/London"
$start = strtotime($user->week_start); // "2024-03-25"
$end = strtotime('+7 days', $start);
The problem: strtotime uses the default timezone, not the user’s. On the two servers where date.timezone was still UTC, $start was midnight UTC, while the report’s SQL compared against timestamps stored in local time. Around the DST boundary the two interpretations drifted by 3600 seconds and the window swallowed the wrong day.
What I did was stop mixing the two APIs. I replaced every strtotime in that path with DateTimeImmutable and made the timezone explicit at construction:
$tz = new DateTimeZone($user->timezone);
$start = new DateTimeImmutable($user->week_start, $tz);
$end = $start->modify('+7 days');
$sqlStart = $start->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
$sqlEnd = $end->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
And I added a guard in CI: a small test that runs the report builder under date_default_timezone_set('UTC') and under Europe/London and asserts the same row IDs come back. That test failed immediately on the old code, which is how I convinced the rest of the team this wasn’t a fluke.
The mechanism is boring once you see it: strtotime has no concept of which zone the string meant. It parses in the process default and returns a Unix timestamp; if your storage layer also stores naive local strings, you’ve got two clocks silently disagreeing. DateTimeImmutable with an explicit zone makes the conversion a step you can read.
The cost is verbosity. $start->modify('+7 days') across a DST boundary gives you wall-clock seven days, not 7×86400 seconds. For “next 7 days” that’s what we wanted, but for a rate limiter or a token TTL it’s wrong, and I’ve since had to go back and switch those call sites to add(new DateInterval('PT168H')). The other trap is modify('+1 month') on the 31st landing on March 3rd; we hit that in a billing cycle and had to clamp explicitly.
Where this still breaks: user-supplied strings from strtotime-style parsing (“next friday”) are still relative to the default zone, so I now always pass a base DateTimeImmutable as the second argument instead of relying on defaults. And if your MySQL session timezone differs from PHP’s, the formatting step above is still only half the fix.
What does your team do for date ranges that cross a DST boundary, store UTC everywhere and convert at the edge, or keep local time in the database and accept the pain?


0 条评论
还没有评论,写下第一条。