Blog
Your Server Doesn't Live in Your Timezone
- software-engineering
- php
- laravel
- typescript
Somebody asks for a "daily report at midnight." Cool. Whose midnight?
The server's? The company's? The customer in Phoenix who doesn't do daylight saving time? That one question is behind a lot of date bugs, and the fix isn't clever. It's a few rules you follow every time.
Store moments in UTC
A moment is a point in time. An order was placed. A job ran. A user logged in. Those get stored in UTC, period.
Laravel already defaults to this in config/app.php:
'timezone' => 'UTC',Leave it alone. Changing it to your local zone feels helpful until you have users in two time zones, or until the clocks change and an hour of data overlaps itself.
On the database side, know what your column does. In MySQL, TIMESTAMP converts from the session time zone to UTC on the way in and back on the way out. DATETIME stores exactly what you hand it with no time zone at all. Either can work. Just make sure the app is always handing it UTC, so nobody has to guess later.
Store the user's timezone as a name, not an offset
-05:00 is not a timezone. It's what New York looks like for part of the year. In the summer it's -04:00.
Store the IANA name instead, like America/New_York or America/Phoenix. It knows the daylight saving rules so you don't have to.
Laravel will validate it for you:
$request->validate([
'timezone' => ['required', 'timezone'],
]);And the browser will tell you what the user is probably in:
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// "America/New_York"Send that up at signup or settings and save it. Let them change it. People travel.
Convert at the edges
Same rule as money. The middle of the app works in one format. Conversion happens on the way in and on the way out.
On the way in, parse the input in the user's zone, then convert:
$startsAt = Carbon::createFromFormat('Y-m-d H:i', $input, $user->timezone)->utc();"9:00 AM" from a user in New York becomes the correct UTC moment, and that's what you store.
On the way out, convert for display and nothing else:
$order->created_at->setTimezone($user->timezone)->format('M j, g:i A');The API should send UTC with the offset on it. Laravel's default JSON serialization of dates gives you an ISO 8601 string ending in Z, which is exactly what you want. Let the frontend format it:
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: user.timezone,
dateStyle: "medium",
timeStyle: "short",
});
formatter.format(new Date(order.created_at));Pass the timezone explicitly. If you don't, you get the browser's zone, which is fine right up until an admin in another state is looking at a customer's account.
"Today" depends on who's asking
This one sneaks into dashboards. You want today's orders, so you write:
Order::whereDate('created_at', today())->count();That's today in UTC. For a user on the East Coast, "today" in UTC ends at 8 PM their time. Their evening orders show up on tomorrow's numbers.
Build the boundaries in the user's zone, then convert them to UTC before you query:
$tz = $user->timezone;
Order::whereBetween('created_at', [
now($tz)->startOfDay()->utc(),
now($tz)->endOfDay()->utc(),
])->count();Eloquent formats whatever Carbon instance you hand it. It doesn't convert time zones for you. So the utc() call isn't optional.
A birthday is not a moment
Not everything with a date is a point in time. A birthday, a due date, a holiday. Those are calendar dates. They don't have a time zone because they aren't tied to one.
Store them as DATE, and be careful in JavaScript:
new Date("2026-10-07").toLocaleDateString("en-US");
// "10/6/2026" if you're anywhere west of UTCA date-only string gets parsed as midnight UTC, then shown in local time, which is the day before for most of the Americas. For calendar dates, keep them as strings or split them yourself:
const [year, month, day] = "2026-10-07".split("-").map(Number);
const date = new Date(year, month - 1, day); // local midnight, right dayOr use a library built for this. Just don't let a birthday move because it crossed a time zone it never belonged to.
Schedules need a timezone too
A job that runs "every day at 9" should mean 9 for the people who care about it, all year.
Schedule::command('reports:daily')
->dailyAt('09:00')
->timezone('America/New_York');Without the timezone() call, an app set to UTC runs it at 9 UTC, and the local time shifts by an hour twice a year. Also avoid scheduling things in the early morning hours when clocks jump. In the US, 2:30 AM doesn't exist on the night the clocks spring forward, and on the night they fall back, 1:30 AM happens twice.
The habit
Store moments in UTC. Store the user's zone as a name. Convert on the way in and on the way out. Treat calendar dates as dates, not timestamps.
Your server doesn't live in your timezone. Your users don't all live in one either. Write the code like you know that.