---
title: "Звіт, який зсунувся на три години: правила роботи з часовими поясами в бізнес-застосунках на Laravel"
url: https://laravelukraine.com/blog/zvit-iakii-zsunuvsia-na-tri-godini-pravila-roboti-z-casovimi-poiasami-v-biznes-zastosunkax-na-laravel
date: 2026-11-03
source: https://dev.to/developertugrul/the-report-that-was-off-by-three-hours-timezone-rules-for-laravel-business-apps-5i0
---

# Звіт, який зсунувся на три години: правила роботи з часовими поясами в бізнес-застосунках на Laravel

Останньої неділі жовтня більша частина Європи переводить годинники на годину назад. 25 жовтня 2026 року «сьогодні» в Берліні тривало 25 годин. Якщо ви пишете бізнес-софт, це гарний момент поставити собі трохи незручне питання: а ваші звіти знають, який сьогодні день?

Мої колись не знали. Щоденний звіт про продажі щоранку недораховував кілька замовлень, і ці замовлення з'являлися в підсумку за попередній день. Нічого не губилося - воно просто лежало не в тому кошику. Мені знадобилося більше часу, ніж хотілося б визнати, щоб побачити закономірність: кожне «зникле» замовлення було зроблене між північчю і 03:00 за місцевим часом. Сервер працював в UTC, бізнес - в UTC+3, а звіт питав у бази «сьогодні» в UTC.

Нижче - набір правил, яких я відтоді тримаюся. Жодне з них не є хитрим. Вони просто не дають помилці повернутися.

> **Примітка перекладача.** Автор наводить приклади зі Стамбула (`Europe/Istanbul`), де цілий рік UTC+3. Для Києва (`Europe/Kyiv`) логіка та сама, з однією поправкою: Україна переводить годинники, тож місцева північ - це 21:00 UTC влітку і 22:00 UTC взимку. Тобто «зсув» звіту в нас то три години, то дві, і правила нижче розраховані саме на такий випадок.

### Проблема: «сьогодні» залежить від того, хто питає

Початковий запит до бази виглядав нешкідливо:

```php
  $total = Order::whereDate('created_at', today())->sum('total');
```

Тут дві проблеми.

1. **`today()` використовує часовий пояс застосунку.** Свіжий застосунок на Laravel 13 має `'timezone' => 'UTC'` у `config/app.php`. Тож `today()` означає «сьогодні» в UTC, а не «сьогодні» для людини, яка читає звіт.
2. **`whereDate()` порівнює дату збереженого UTC-значення.** Замовлення, зроблене о 01:30 у Стамбулі, зберігається як 22:30 UTC попереднього дня. Його UTC-дата - вчора, тож воно й рахується вчорашнім.

Є ще й ціна для продуктивності: `whereDate()` загортає стовпець у виклик `DATE()`, і через це MySQL зазвичай не може скористатися звичайним індексом на `created_at`.

### Правило 1: зберігайте моменти часу в UTC і кажіть про це двічі

Лишайте `app.timezone` рівним `UTC`. Не змінюйте його на часовий пояс офісу, «щоб звіти було простіше рахувати». Простіше буде рівно до першого клієнта з іншого часового поясу або до першого поясу з переходом на літній час.

Потім змусьте погодитися й сесію бази даних. MySQL-конектор Laravel читає ключ `timezone` з конфігурації підключення і під час з'єднання виконує `SET time_zone=...`:

```php
  // 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 і порівнюйте з ним сирий стовпець.

```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` були б помилкою двічі на рік.

Запит для звіту стає таким:

```php
  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`, бо зсув нічого не знає про правила переходу на літній час.

```php
  // app/Models/Tenant.php (фрагмент)
  public function reportingTimezone(): string
  {
      return $this->timezone ?: 'UTC';
  }
```

Під час збереження перевіряйте значення правилом валідації Laravel `timezone`, щоб одна помилка в назві не зламала всі звіти цього орендаря.

### Правило 4: групуйте за локальним днем в одному місці

Щоденному графіку потрібні суми за кожен локальний день. Є два розумні варіанти.

**Для малих і середніх вибірок:** отримайте рядки за весь діапазон і згрупуйте їх у PHP.

```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) до дати:

```php
  // 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 віддає саме їх, а конвертуйте лише під час відображення:

```javascript
  // 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() не округлює: як отримати точний відносний час»](https://laravelukraine.com/blog/diffforhumans-ne-okrugliuje-iak-otrimati-tocnii-vidnosnii-cas).

### Типові помилки

- **`whereDate()` над UTC-стовпцем для локального звіту.** Він порівнює UTC-дати. Використовуйте напіввідкритий UTC-діапазон.
- **День, що закінчується о 23:59:59.** Пишіть `< початок наступного дня`. Дробові секунди існують.
- **Зберігання зсувів замість назв поясів.** `+03:00` правильний сьогодні й стане неправильним того дня, коли країна змінить правила або орендар переїде.
- **Зміна `app.timezone`, щоб полагодити звіт.** Ви полагодите один звіт і зламаєте всі вже збережені позначки часу.
- **Планування звітів у поясі з переходом на літній час.** Laravel дозволяє викликати `->timezone('Europe/Berlin')` на запланованому завданні або задати `schedule_timezone`, але документація попереджає: під час переходу на літній чи зимовий час завдання може виконатися двічі або не виконатися зовсім. Для щоденного звіту безпечніше планувати в UTC, а локальний день обчислювати всередині завдання.
- **Тестування лише на сервері у вашому власному часовому поясі.** Напишіть тест на день із переходом годинника і на замовлення, зроблене одразу після місцевої півночі. Заморожування часу через `$this->travelTo()` робить такі тести простими.

### Підсумок

Помилка у звіті виросла з одного припущення: що «сьогодні» - це властивість сервера. Це не так. Це властивість того, хто читає.

1. Зберігайте моменти часу в **UTC** - і в PHP, і в сесії бази даних.
2. Перетворюйте локальний день на **напіввідкритий UTC-діапазон** і порівнюйте з ним сирий стовпець.
3. Тримайте часовий пояс звітності як **IANA-назву в даних**.
4. Групуйте за локальним днем **в одному місці** і знайте, що потрібно для цього вашій базі.
5. Зберігайте **календарні дати** як дати, а не як позначки часу опівночі.
6. Конвертуйте для **показу на самому краю**, з явно заданим часовим поясом.
