Останньої неділі жовтня більша частина Європи переводить годинники на годину назад. 25 жовтня 2026 року «сьогодні» в Берліні тривало 25 годин. Якщо ви пишете бізнес-софт, це гарний момент поставити собі трохи незручне питання: а ваші звіти знають, який сьогодні день?
Мої колись не знали. Щоденний звіт про продажі щоранку недораховував кілька замовлень, і ці замовлення з'являлися в підсумку за попередній день. Нічого не губилося - воно просто лежало не в тому кошику. Мені знадобилося більше часу, ніж хотілося б визнати, щоб побачити закономірність: кожне «зникле» замовлення було зроблене між північчю і 03:00 за місцевим часом. Сервер працював в UTC, бізнес - в UTC+3, а звіт питав у бази «сьогодні» в UTC.
Нижче - набір правил, яких я відтоді тримаюся. Жодне з них не є хитрим. Вони просто не дають помилці повернутися.
Примітка перекладача. Автор наводить приклади зі Стамбула (Europe/Istanbul), де цілий рік UTC+3. Для Києва (Europe/Kyiv) логіка та сама, з однією поправкою: Україна переводить годинники, тож місцева північ - це 21:00 UTC влітку і 22:00 UTC взимку. Тобто «зсув» звіту в нас то три години, то дві, і правила нижче розраховані саме на такий випадок.
Проблема: «сьогодні» залежить від того, хто питає
Початковий запит до бази виглядав нешкідливо:
$total = Order::whereDate('created_at', today())->sum('total');
Тут дві проблеми.
today() використовує часовий пояс застосунку. Свіжий застосунок на Laravel 13 має 'timezone' => 'UTC' у config/app.php. Тож today() означає «сьогодні» в UTC, а не «сьогодні» для людини, яка читає звіт.
whereDate() порівнює дату збереженого UTC-значення. Замовлення, зроблене о 01:30 у Стамбулі, зберігається як 22:30 UTC попереднього дня. Його UTC-дата - вчора, тож воно й рахується вчорашнім.
Є ще й ціна для продуктивності: whereDate() загортає стовпець у виклик DATE(), і через це MySQL зазвичай не може скористатися звичайним індексом на created_at.
Правило 1: зберігайте моменти часу в UTC і кажіть про це двічі
Лишайте app.timezone рівним UTC. Не змінюйте його на часовий пояс офісу, «щоб звіти було простіше рахувати». Простіше буде рівно до першого клієнта з іншого часового поясу або до першого поясу з переходом на літній час.
Потім змусьте погодитися й сесію бази даних. MySQL-конектор Laravel читає ключ timezone з конфігурації підключення і під час з'єднання виконує SET time_zone=...:
// config/database.php (фрагмент підключення mysql)
'mysql' => [
'driver' => 'mysql',
// ...
'timezone' => '+00:00',
],
Навіщо це? MySQL під час запису конвертує значення TIMESTAMP з часового поясу сесії в UTC, а під час читання - назад, а DATETIME не конвертує взагалі. Якщо часовий пояс сервера за замовчуванням не UTC і ви використовуєте стовпці TIMESTAMP, то NOW() і збережені значення можуть тихо розходитися з тим, що думає PHP. Закріплення сесії на +00:00 прибирає цю змінну. Числовий зсув я використовую навмисно: іменовані пояси на кшталт 'UTC' працюють, лише якщо в MySQL завантажено таблиці часових поясів, а на багатьох серверах їх немає.
Правило 2: конвертуйте межі, а не стовпець
Замість того щоб просити базу перетворювати кожен рядок на локальну дату, один раз перетворіть локальний день на UTC-діапазон у PHP і порівнюйте з ним сирий стовпець.
// app/Support/LocalDayRange.php
namespace App\Support;
use Carbon\CarbonImmutable;
final class LocalDayRange
{
/**
* Перетворює локальний календарний день на напіввідкритий UTC-діапазон: [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()];
}
}
Тут важливі три деталі:
- Дата розбирається в цільовому часовому поясі.
25 жовтня в Europe/Istanbul починається о 2026-10-24 21:00:00 UTC.
- Кінець - це «початок наступного дня», а не 23:59:59. Діапазон стає напіввідкритим, тож запис о 23:59:59.5 не провалиться в щілину.
addDay() на локальному часі враховує перехід на літній і зимовий час. У Europe/Berlin діапазон для 25 жовтня 2026 року триває 25 годин, і це саме те, що треба. Жорстко закодовані +24 hours були б помилкою двічі на рік.
Запит для звіту стає таким:
use App\Models\Order;
use App\Support\LocalDayRange;
$day = $request->input('day', now($tz)->toDateString()); // валідується як Y-m-d
[$from, $to] = LocalDayRange::for($day, $tz);
$total = Order::query()
->where('created_at', '>=', $from)
->where('created_at', '<', $to)
->sum('total');
Тут $tz - це часовий пояс звітності, що підводить нас до наступного правила.
Правило 3: часовий пояс належить людині чи орендарю, а не серверу
Відповідь на питання «у якому часовому поясі цей звіт?» має зберігатися в даних. У застосунку для однієї компанії це одне значення в конфігурації. У застосунку з кількома орендарями (Multi-tenant) це стовпець в орендаря (і, можливо, перевизначення в користувача). Зберігайте IANA-назви на кшталт Europe/Istanbul і ніколи - зсуви на кшталт +03:00, бо зсув нічого не знає про правила переходу на літній час.
// app/Models/Tenant.php (фрагмент)
public function reportingTimezone(): string
{
return $this->timezone ?: 'UTC';
}
Під час збереження перевіряйте значення правилом валідації Laravel timezone, щоб одна помилка в назві не зламала всі звіти цього орендаря.
Правило 4: групуйте за локальним днем в одному місці
Щоденному графіку потрібні суми за кожен локальний день. Є два розумні варіанти.
Для малих і середніх вибірок: отримайте рядки за весь діапазон і згрупуйте їх у 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'));
Для великих вибірок: нехай це зробить база через CONVERT_TZ(created_at, '+00:00', 'Europe/Istanbul'). Для цього в MySQL мають бути завантажені таблиці часових поясів (mysql_tzinfo_to_sql). Без них іменовані пояси не працюють, і з'являється спокуса взяти фіксований зсув, а він безпечний лише для поясів без переходу на літній час. Якщо наявність таблиць гарантувати не можна, обчисліть окремий UTC-діапазон для кожного дня через LocalDayRange і виконайте один запит до бази з CASE на кожен день або попередньо агрегуйте дані в таблицю денних підсумків у запланованому завданні.
Правило 5: не кожна дата - це момент часу
Цей урок після першого вберіг мене від найбільшої кількості помилок. Одні значення - це моменти часу: created_at, paid_at, shipped_at. Інші - календарні дати: дата рахунку, термін оплати, обіцяна клієнтові дата доставки, день народження.
Календарні дати не мають часового поясу. Якщо зберегти термін оплати як datetime опівночі, а потім конвертувати в часовий пояс глядача, то глядач на захід від UTC побачить його на день раніше. Зберігайте такі значення в стовпцях DATE і застосовуйте до них приведення (Casts) до дати:
// app/Models/Invoice.php (фрагмент)
protected function casts(): array
{
return [
'issued_on' => 'date:Y-m-d',
'due_on' => 'date:Y-m-d',
'paid_at' => 'datetime',
];
}
Формат :Y-m-d важить більше, ніж здається. Звичайне приведення date однаково серіалізується в JSON як повна UTC-позначка часу опівночі (2026-10-25T00:00:00.000000Z), і фронтенд, який конвертує її в західний часовий пояс, покаже попередній день. З date:Y-m-d API віддає 2026-10-25, і конвертувати просто нічого.
Іменування (_on для дат, _at для моментів часу) - дрібна звичка, але вона робить різницю помітною під час рев'ю коду.
Правило 6: конвертуйте для показу на самому краю
Laravel серіалізує дати Eloquent через метод Carbon toJSON(), який повертає рядки ISO-8601 в UTC, наприклад 2026-10-24T22:30:00.000000Z. Нехай API віддає саме їх, а конвертуйте лише під час відображення:
// 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'
Передавайте часовий пояс орендаря чи користувача явно, а не покладайтеся на браузер. Менеджер у відрядженні за кордоном однаково має бачити робочий день компанії.
До речі, про ще одну пастку роботи з часом - як diffForHumans() округлює відносний час - ми писали в статті «diffForHumans() не округлює: як отримати точний відносний час».
Типові помилки
whereDate() над UTC-стовпцем для локального звіту. Він порівнює UTC-дати. Використовуйте напіввідкритий UTC-діапазон.
- День, що закінчується о 23:59:59. Пишіть
< початок наступного дня. Дробові секунди існують.
- Зберігання зсувів замість назв поясів.
+03:00 правильний сьогодні й стане неправильним того дня, коли країна змінить правила або орендар переїде.
- Зміна
app.timezone, щоб полагодити звіт. Ви полагодите один звіт і зламаєте всі вже збережені позначки часу.
- Планування звітів у поясі з переходом на літній час. Laravel дозволяє викликати
->timezone('Europe/Berlin') на запланованому завданні або задати schedule_timezone, але документація попереджає: під час переходу на літній чи зимовий час завдання може виконатися двічі або не виконатися зовсім. Для щоденного звіту безпечніше планувати в UTC, а локальний день обчислювати всередині завдання.
- Тестування лише на сервері у вашому власному часовому поясі. Напишіть тест на день із переходом годинника і на замовлення, зроблене одразу після місцевої півночі. Заморожування часу через
$this->travelTo() робить такі тести простими.
Підсумок
Помилка у звіті виросла з одного припущення: що «сьогодні» - це властивість сервера. Це не так. Це властивість того, хто читає.
- Зберігайте моменти часу в UTC - і в PHP, і в сесії бази даних.
- Перетворюйте локальний день на напіввідкритий UTC-діапазон і порівнюйте з ним сирий стовпець.
- Тримайте часовий пояс звітності як IANA-назву в даних.
- Групуйте за локальним днем в одному місці і знайте, що потрібно для цього вашій базі.
- Зберігайте календарні дати як дати, а не як позначки часу опівночі.
- Конвертуйте для показу на самому краю, з явно заданим часовим поясом.