On the last Sunday of October, most of Europe moves its clocks back one hour. On 25 October 2026, "today" in Berlin was 25 hours long. If you run business software, that is a good moment to ask a slightly uncomfortable question: do your reports know what day it is?
Mine once didn't. A daily sales report was a few orders short every morning, and those orders showed up in the previous day's total instead. Nothing was lost; it was just in the wrong bucket. It took longer than I'd like to admit to see the pattern: every "missing" order had been placed between midnight and 03:00 local time. The server ran in UTC, the business ran in UTC+3, and the report query asked the database for "today" in UTC.
This post is the set of rules I've used since then. None of them are clever. They just stop the bug from coming back.
The problem: "today" depends on who is asking
The original query looked harmless:
$total = Order::whereDate('created_at', today())->sum('total');
There are two problems here.
-
today()uses the app timezone. A fresh Laravel 13 app has'timezone' => 'UTC'inconfig/app.php. Sotoday()means today in UTC, not today for the person reading the report. -
whereDate()compares the stored UTC value's date part. An order placed at 01:30 in Istanbul is stored as 22:30 UTC on the previous day. Its UTC date is yesterday, so it is counted under yesterday.
There is also a performance cost: whereDate() wraps the column in a DATE() call, which usually stops MySQL from using a plain index on created_at.
Rule 1: Store instants in UTC, and say so twice
Keep app.timezone as UTC. Don't change it to your office's timezone "to make reports easier". It makes them easier until you get your first customer in another timezone, or until a timezone with daylight saving time is involved.
Then make the database session agree. Laravel's MySQL connector reads a timezone key from the connection config and runs SET time_zone=... when it connects:
// config/database.php (mysql connection excerpt)
'mysql' => [
'driver' => 'mysql',
// ...
'timezone' => '+00:00',
],
Why bother? MySQL converts TIMESTAMP values from the session time zone to UTC on write and back on read, and it does not convert DATETIME at all. If the server's default timezone is not UTC and you use TIMESTAMP columns, NOW() and your stored values can quietly disagree with what PHP thinks. Pinning the session to +00:00 removes that variable. I use a numeric offset on purpose: named zones like 'UTC' only work if the MySQL timezone tables are loaded, and many servers don't load them.
Rule 2: Convert the boundaries, not the column
Instead of asking the database to turn every row into a local date, turn the local day into a UTC range once, in PHP, and compare the raw column against it.
// app/Support/LocalDayRange.php
namespace App\Support;
use Carbon\CarbonImmutable;
final class LocalDayRange
{
/**
* Turn a local calendar day into a half-open UTC range: [start, end).
*
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
*/
public static function for(string $date, string $timezone): array
{
$start = CarbonImmutable::createFromFormat('!Y-m-d', $date, $timezone)->startOfDay();
$end = $start->addDay();
return [$start->utc(), $end->utc()];
}
}
Three details matter:
-
The date is parsed in the target timezone.
25 October in Europe/Istanbulstarts at2026-10-24 21:00:00 UTC. - The end is "start of the next day", not 23:59:59. That makes the range half-open, so nothing at 23:59:59.5 falls through a gap.
-
addDay()on a local time handles DST. InEurope/Berlin, the range for 25 October 2026 is 25 hours long, which is exactly right. Hardcoding+24 hourswould be wrong twice a year.
The report query becomes:
use App\Models\Order;
use App\Support\LocalDayRange;
$day = $request->input('day', now($tz)->toDateString()); // validated as Y-m-d
[$from, $to] = LocalDayRange::for($day, $tz);
$total = Order::query()
->where('created_at', '>=', $from)
->where('created_at', '<', $to)
->sum('total');
Here $tz is the reporting timezone, which brings us to the next rule.
Rule 3: The timezone belongs to a person or a tenant, not the server
"Which timezone is the report in?" should have an answer stored in data. In a single-company app, that's one config value. In a multi-tenant app, it's a column on the tenant (and maybe an override on the user). Store IANA names like Europe/Istanbul, never offsets like +03:00, because offsets don't know about daylight saving rules.
// app/Models/Tenant.php (excerpt)
public function reportingTimezone(): string
{
return $this->timezone ?: 'UTC';
}
Validate it when it's saved with Laravel's timezone validation rule, so a typo can't break every report for that tenant.
Rule 4: Group by local day in one place
A daily chart needs totals per local day. You have two reasonable options.
Small to medium result sets: fetch the rows for the whole range and group in PHP.
$perDay = Order::query()
->where('created_at', '>=', $from)
->where('created_at', '<', $to)
->get(['created_at', 'total'])
->groupBy(fn (Order $order) => $order->created_at->setTimezone($tz)->toDateString())
->map(fn ($orders) => $orders->sum('total'));
Large result sets: let the database do it with CONVERT_TZ(created_at, '+00:00', 'Europe/Istanbul'). This needs the MySQL timezone tables to be loaded (mysql_tzinfo_to_sql). Without them, named zones fail and you'll be tempted to use a fixed offset, which is only safe for zones that don't observe DST. If you can't guarantee the tables, compute one UTC range per day with LocalDayRange and run a single query with a CASE per bucket, or pre-aggregate into a daily summary table in a scheduled job.
Rule 5: Not every date is an instant
This is the lesson that saved the most bugs after the first one. Some values are moments in time: created_at, paid_at, shipped_at. Others are calendar dates: an invoice date, a due date, a delivery date promised to a customer, a birthday.
Calendar dates have no timezone. If you store a due date as a datetime at midnight and then convert it to the viewer's timezone, a viewer west of UTC sees it one day early. Store those as DATE columns and cast them as dates:
// app/Models/Invoice.php (excerpt)
protected function casts(): array
{
return [
'issued_on' => 'date:Y-m-d',
'due_on' => 'date:Y-m-d',
'paid_at' => 'datetime',
];
}
The :Y-m-d format matters more than it looks. A plain date cast still serializes to JSON as a full UTC timestamp at midnight (2026-10-25T00:00:00.000000Z), and a frontend that converts it to a western timezone shows the previous day. With date:Y-m-d, the API sends 2026-10-25 and there is nothing to convert.
The naming (_on for dates, _at for instants) is a small habit, but it makes the difference visible in code review.
Rule 6: Convert for display at the edge
Laravel serializes Eloquent dates with Carbon's toJSON(), which produces ISO-8601 strings in UTC, like 2026-10-24T22:30:00.000000Z. Let the API return that and convert only when rendering:
// resources/js/lib/formatLocal.js
export function formatLocal(isoUtc, timeZone, locale = 'en-GB') {
return new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(isoUtc));
}
// formatLocal('2026-10-24T22:30:00.000000Z', 'Europe/Istanbul') -> '25 Oct 2026, 01:30'
// formatLocal('2026-10-24T22:30:00.000000Z', 'UTC') -> '24 Oct 2026, 22:30'
Pass the tenant's or user's timezone explicitly rather than relying on the browser's. A manager travelling abroad should still see the company's business day.
Common pitfalls
-
whereDate()on a UTC column for a local report. It compares UTC dates. Use a half-open UTC range instead. -
Ending a day at 23:59:59. Use
< start of next day. Fractional seconds exist. -
Storing offsets instead of zone names.
+03:00is correct today and wrong the day a country changes its rules or a tenant moves. -
Changing
app.timezoneto fix a report. You'll fix one report and break every timestamp you've already stored. -
Scheduling reports in a DST zone. Laravel lets you call
->timezone('Europe/Berlin')on a scheduled task or setschedule_timezone, but the docs warn that during DST changes a task may run twice or not at all. For a daily report, scheduling in UTC and computing the local day inside the job is safer. -
Testing only on a server in your own timezone. Write a test for a day with a DST change and for an order placed just after local midnight. Freezing time with
$this->travelTo()makes these tests easy.
Summary
The report bug came from one assumption: that "today" is a property of the server. It isn't. It's a property of the reader.
- Store instants in UTC, in PHP and in the database session.
- Turn a local day into a half-open UTC range and compare the raw column.
- Keep the reporting timezone as an IANA name in data.
- Group by local day in one place, and know what your database needs to do it.
- Keep calendar dates as dates, not midnight timestamps.
- Convert for display at the edge, with an explicit timezone.
What's the strangest timezone bug you've shipped, and how long did it take you to notice?
Top comments (0)