Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама
Переклади 03 листопада 2026

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

Останньої неділі жовтня більша частина Європи переводить годинники на годину назад. 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');

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

  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=...:

  // 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() робить такі тести простими.

Підсумок

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

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

Читати в документації

Коментарі

Увійдіть, щоб залишити коментар

Будьте першим, хто залишить коментар!

Читайте також

Переклади 04 листопада 2026

Спільна мова для PHP: навіщо стандартній бібліотеці SortDirection і Time\Duration

Переказ колонки Тіма Дюстерхуса в блозі PHP Foundation: чому крихітні типи SortDirection і Time\Duration у PHP 8.6 важать більше за свій розмір, чому від'ємні тривалості заклали з першої версії і що дадуть Time\Instant та Time\Clock.

Переклади 03 листопада 2026

Як приймати вебхуки в Laravel і не обпектися: підписи, захист від повторів та ідемпотентність

Покроково будуємо надійний приймач вебхуків у Laravel: перевірка HMAC-підпису через hash_equals, вікно часу проти повторного відтворення, унікальний індекс замість перевірки «чи існує», обробка через чергу та ідемпотентне завдання з тестом на справжній підпис.

19

Вакансії за темою

Tech Lead — Fintech

Tech Lead для фінтех-платформи бізнес-витрат і платежів: активна розробка, архітектурні рішення, стабільність продакшену та менторство команди. Ключовий стек: PHP, Laravel/Lumen, MySQL, Redis, Docker, знання JavaScript (Vue.js, React на базовому рівні). Вимоги: розуміння життєвого циклу карткових транзакцій і криптовалют, досвід побудови надійних фінансових систем (баланси, звірка, аудит, безпечні API), DevOps-навички на Linux.

Vodworks Нова
Сьогодні

Full Stack (PHP / JavaScript / AI)

Senior-розробник на позиції індивідуального контриб'ютора без керування людьми: архітектура та розробка масштабованих застосунків, мікросервісів і API, переважно на PHP з Laravel, а також JavaScript і сучасний фронтенд. Завдання включають обробку даних у реальному часі, кешування, черги повідомлень і розподілені системи. Вимоги: 7+ років досвіду, практичне використання AI-інструментів у розробці, англійська C1 і вище, менторство молодших розробників.

Full Stack Developer (PHP / React) Middle / Middle+

Full Stack розробник розвиватиме та підтримуватиме власний аналітичний продукт для перевірки контрагентів і оцінки ризиків, працюючи зі складною бізнес-логікою та великими обсягами даних. Основний стек: PHP 8.x, Laravel або Symfony, MySQL, React; також потрібні проєктування API, оптимізація SQL-запитів, Docker, CI/CD. Вимоги: комерційний досвід від 3 років, досвід підтримки legacy-коду, англійська B1+ для читання документації.

Пакети за темою

Kalimera

welldigit/kalimera

Interactive installer that scaffolds Laravel applications the Well Digit way.

v1.0.0 16

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

28,325 v2.5.0 13 28