<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
CORE PHP · JUNIOR ЧАСТО ПИТАЮТЬ

DateTime чи DateTimeImmutable, і як не заплутатись у часових поясах?

`DateTime` змінює сам себе на кожному `modify()`/`add()`, тому одна змінна тихо міняється в усіх місцях, де на неї посилаються; `DateTimeImmutable` має той самий API, але повертає новий екземпляр. Час зберігають як UTC-мітку, а пояс користувача застосовують лише на виводі через `setTimezone()` і завжди за ідентифікатором зони, а не за зсувом.

У мене `$end = $start->modify('+7 days')`, а потім виявляється, що `$start` теж дорівнює `$end`. Чому?
Чим `DateTimeImmutable` відрізняється від `DateTime` і що брати за замовчуванням?
У якому поясі ви зберігаєте дати в базі і як показуєте їх користувачу з Варшави?
Що станеться з подією, запланованою на 03:30 29 березня в Києві?
Чому `Carbon::now()->startOfDay()` у циклі зламав усі дати?
DateTime DateTimeImmutable timezones DST Carbon UTC

Різниця між двома класами тримається на одному рядку документації: DateTime::modify() повертає $this, а DateTimeImmutable::modify() - новий обʼєкт. Те саме стосується add(), sub(), setDate(), setTime(), setTimestamp() і setTimezone(). API ідентичний навмисно, щоб DateTimeImmutable (зʼявився в PHP 5.5) можна було підставити замість DateTime майже без переписування коду. Обидва реалізують DateTimeInterface, причому реалізувати цей інтерфейс своїм класом PHP не дозволяє - успадковуватись можна тільки від двох вбудованих.

Баг від мутабельності виглядає завжди однаково. Хтось пише $end = $start->modify('+7 days'), отримує дві змінні, які вказують на один обʼєкт, і далі діапазон «з понеділка по понеділок» перетворюється на нульовий. Так само ламається дата, покладена у властивість сервісу чи в кеш: зовнішній код викликає ->setTime(0, 0) і непомітно переписує спільне значення. Зворотна пастка чекає під час міграції на незмінний клас - рядок $date->modify('+1 hour'); без присвоєння на DateTime працював, а на DateTimeImmutable просто нічого не робить, без помилки й без попередження. Тому міграцію роблять суцільно, з перевіркою кожного виклику: PHPStan на рівні 5 і вище ловить відкинутий результат через правило про безрезультатні виклики, а DateTimeImmutable::createFromInterface() (PHP 8.0) і DateTime::createFromInterface() дають міст там, де чужа бібліотека все ще віддає мутабельний обʼєкт.

Робота з поясами тримається на розділенні двох речей: моменту на осі часу і того, як цей момент виглядає на чиємусь годиннику. У базі має лежати момент, і найпростіший спосіб його зафіксувати - UTC. Конвертація в пояс користувача робиться на самій межі, перед рендером: $utc->setTimezone(new DateTimeZone('Europe/Kyiv')). Зону завжди задавайте ідентифікатором з бази tzdata, а не зсувом: new DateTimeZone('+02:00') створює зону без жодних правил, і влітку все зміститься на годину. До речі, у tzdata 2022b Europe/Kiev перейменували на Europe/Kyiv, старе написання лишилось зворотним посиланням і працює, але в новому коді пишіть нову назву. Саму базу зон PHP бере або з системи, або з розширення timezonedb з PECL - на довгоживучих серверах її треба оновлювати, інакше свіжі політичні рішення туди не доїдуть.

Перехід на літній час перетворює локальний час із суцільної осі на вісь із дірками й повторами. У Києві 29 березня 2026 року о 03:00 годинник стрибає на 04:00, тож локальних значень від 03:00:00 до 03:59:59 того дня не існує; 25 жовтня о 04:00 він повертається на 03:00, і година 03:xx трапляється двічі - спершу зі зсувом +03:00, потім з +02:00. PHP не кидає винятку в жодному з цих випадків: для неіснуючого часу він бере зсув до переходу (03:30 тихо стає 04:30+03:00), для неоднозначного - перше, літнє входження. Звідси й різниця між +1 day і +24 hours: перше додає календарну добу, зберігаючи показ годинника, друге додає 86400 секунд. Те саме з інтервалами, P1D і PT24H у дні переходу розходяться на годину. Тому планувальник, крони й ретраї тримайте в UTC, а «кожного дня о 09:00 за Києвом» рахуйте через локальну зону, щоб лист приходив в одну й ту саму годину на годиннику користувача.

Carbon нічого в цій механіці не змінює, а лише додає зручні методи поверх. Carbon успадковує DateTime і мутує так само боляче: $week = $day->startOfDay() у циклі зіпсує оригінал. CarbonImmutable успадковує DateTimeImmutable і поводиться як значення. У Laravel глобальний перемикач ставиться одним рядком у AppServiceProvider::boot() - Date::use(CarbonImmutable::class), після чого now() і хелпери віддають незмінні обʼєкти; для моделей є касти immutable_date, immutable_datetime і immutable_custom_datetime. Laravel 11 допускає Carbon 2 і 3, Laravel 12 вимагає третю версію, і при оновленні тримайте в голові зміну поведінки diffInDays() і сусідів: у Carbon 3 вони повертають float і враховують знак, тож код, який спирався на ціле невідʼємне число, мовчки починає рахувати інакше. Ціна незмінності - один новий обʼєкт на кожну операцію, що на тлі будь-якого запиту до бази не вимірюється; відчутним це стає хіба що в циклі на сотні тисяч ітерацій, і там зазвичай правильніше рахувати на цілих мітках часу, а не на обʼєктах дат.

declare(strict_types=1);

$kyiv = new DateTimeZone('Europe/Kyiv');

// DateTime змінює сам себе, а modify() повертає той самий обʼєкт
$start = new DateTime('2026-03-28 20:00', $kyiv);
$end = $start->modify('+1 day');
echo $start->format('d.m H:i'), "\n";    // 29.03 20:00 - оригінал теж поїхав
var_dump($start === $end);               // true: дві змінні, один обʼєкт

// DateTimeImmutable: той самий API, але результат - новий екземпляр
$start = new DateTimeImmutable('2026-03-28 20:00', $kyiv);
$end = $start->modify('+1 day');
echo $start->format('d.m H:i P'), "\n";  // 28.03 20:00 +02:00
echo $end->format('d.m H:i P'), "\n";    // 29.03 20:00 +03:00 - уже літній час

// «+1 day» тримає час на годиннику, «+24 hours» додає реальні 24 години
$night = new DateTimeImmutable('2026-03-28 23:30', $kyiv);
echo $night->modify('+1 day')->format('d.m H:i'), "\n";     // 29.03 23:30
echo $night->modify('+24 hours')->format('d.m H:i'), "\n";  // 30.03 00:30

// 29.03.2026 о 03:00 годинник стрибає на 04:00: цієї години не існує
$ghost = new DateTimeImmutable('2026-03-29 03:30', $kyiv);
echo $ghost->format('H:i P'), "\n";      // 04:30 +03:00 - PHP зсунув мовчки

// У базу йде UTC, пояс користувача застосовуємо тільки на виводі
$utc = $ghost->setTimezone(new DateTimeZone('UTC'));
echo $utc->format('Y-m-d H:i:s'), "\n";                    // 2026-03-29 01:30:00
echo $utc->setTimezone($kyiv)->format('d.m.Y H:i'), "\n";  // 29.03.2026 04:30

// Міст між двома світами (PHP 8.0+)
$mutable = new DateTime('now', $kyiv);
$safe = DateTimeImmutable::createFromInterface($mutable);
var_dump($safe->modify('+1 hour') == $mutable);  // false: оригінал не чіпали
Що `DateTime::modify()`, `add()`, `sub()`, `setDate()`, `setTime()`, `setTimezone()` повертають `$this`, а не копію, тому `$a === $b` після «створення другої дати» дає `true`.
Що `DateTimeImmutable` без присвоєння результату не робить нічого: `$date->modify('+1 day');` окремим рядком - типовий мовчазний no-op.
Що в базі має лежати момент часу в UTC, а пояс - це налаштування відображення, яке застосовують на межі з користувачем.
Що зона задається ідентифікатором (`Europe/Kyiv`), а не зсувом `+02:00` і не абревіатурою `EET`: зсув змінюється двічі на рік, а абревіатури неоднозначні.
Що `+1 day` і `+24 hours` у дні переходу дають різний результат, бо перше рахує по календарю, друге - по реальних секундах.
Що `Carbon` успадковує `DateTime` і мутує, а `CarbonImmutable` успадковує `DateTimeImmutable`; у Laravel перемикач - `Date::use(CarbonImmutable::class)` і каст `immutable_datetime`.
Писати `$end = $start->add(new DateInterval('P1M'));` з `DateTime` і бути впевненим, що `$start` цілий: обидві змінні вказують на один обʼєкт.
Переходити на `DateTimeImmutable`, але залишати старі рядки на кшталт `$date->setTime(0, 0);` без присвоєння - код перестає робити взагалі що завгодно, і тихо.
Зберігати в базі локальний час без зони «бо так зручніше дивитись у phpMyAdmin»: після переходу на зимовий час година 03:xx стає неоднозначною, і впорядкувати події вже неможливо.
Створювати зону як `new DateTimeZone('+02:00')`: такий обʼєкт не знає про DST, і влітку всі дати поїдуть на годину.
Вважати, що `+1 month` до 31 січня дасть 28 лютого: PHP переповнює дату й повертає 3 березня, для «кінця місяця» потрібні `modify('last day of next month')` або Carbon-методи.
Порівнювати `Carbon::now() == $saved` і дивуватись `false`: `now()` містить мікросекунди, яких у значення з бази немає.
Міняти `date_default_timezone_set()` посеред запиту, щоб «показати користувачу його час»: глобальний стан зачепить логування, черги й усе інше в цьому ж процесі.
ПОРАДА

Формулювання, яке закриває питання: «за замовчуванням беру `DateTimeImmutable`, бо дата - це значення, а не змінний стан; у типах параметрів ставлю `DateTimeInterface` для читання і `DateTimeImmutable` для збереження у властивість». Далі одразу назвіть `Europe/Kyiv` і перехід 29 березня: згадка про неіснуючу годину 03:00-03:59 показує, що ви цей баг уже ловили в продакшені.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`DateTime` мутабельний: `modify()`, `add()`, `sub()`, `setTime()`, `setTimezone()` міняють стан обʼєкта й повертають `$this`, тому `$start` і `$end` стають однією датою. `DateTimeImmutable` має ту саму сигнатуру, але щоразу віддає новий екземпляр, а вихідний лишається без змін.