Kick-off times that were two hours wrong: UTC dates in a PHP fixtures list
A while ago I mentioned a small page I use to follow a football league table on my phone. This one is about the other half of that page, the fixtures list, and a bug that took me far too long to notice because it only looked wrong to people who actually went to the games. The times were all exactly two hours early. A 15:30 kick-off showed as 13:30, an evening game at 19:30 showed as 17:30, and nothing crashed or logged a warning.
The fixtures I was comparing against are the ones on chippaunited-fc.co.za, which lists the club's upcoming matches with local kick-off times. My copy pulled the same data from a JSON feed and printed it, and when I put the two side by side on a Saturday morning every single row was off by the same amount. Two hours is the gap between UTC and South African Standard Time, so the cause was not hard to guess. Finding where it happened was the annoying part.
The feed stores each match like this, which is normal and correct:
{ "home": "Chippa United", "away": "Golden Arrows", "kickoff": "2026-03-14T13:30:00Z" }
date() does not know what you meant
The original template did roughly this:
$ts = strtotime($match['kickoff']);
echo date('D j M, H:i', $ts);
strtotime() reads the Z and gives you the right Unix timestamp. The problem is date(). It formats the timestamp in the default timezone of the PHP process, and on the server that default was UTC, because nobody had set date.timezone in php.ini and the container image ships with UTC. On my laptop it was set to Africa/Johannesburg, so locally everything looked fine. That is the worst kind of bug: correct on the machine where you test, wrong on the one people use.
I did not want to fix it by changing the server default. Other code on the same box writes logs and cache keys with date(), and silently shifting all of those by two hours felt like trading one bug for three. The timezone belongs to the display, not to the server, so the fix should live in the display code.
Format with an explicit timezone
The version that works uses DateTimeImmutable and says out loud which zone it wants:
$tz = new DateTimeZone('Africa/Johannesburg');
$dt = (new DateTimeImmutable($match['kickoff']))->setTimezone($tz);
echo $dt->format('D j M, H:i');
The constructor keeps the UTC instant from the Z, and setTimezone() only changes how it is shown. If this runs inside WordPress, wp_date() does the same job and uses the timezone from Settings › General, which is what I ended up using in the theme so the site owner can change it without touching code. Either way, the rule I follow now is simple: store and compare in UTC, convert once, at the moment you print.
Two smaller things came out of it. First, I added a test that formats a fixed UTC time and checks the hour, and I run it with TZ=UTC on purpose, because a test that passes only in your own timezone is not testing much. Second, I stopped printing a bare time anywhere. If a page shows 19:30 without a date and the conversion is ever wrong again, the game can move to the wrong day and nobody notices until the bus leaves. South Africa has no daylight saving, which made this easier to debug than it would be in Europe, but I would still not trust a fixtures list I had not checked against a real one on a match day.
All rights reserved