Работа с датами в PHP: разбор на живом примере
Клиент видит в личном кабинете подписку, которая якобы истекла вчера, хотя оплатил её час назад. Виноват не код бизнес-логики, а незаметная ошибка с часовыми поясами и мутабельностью объектов дат.

Есть типичная задача: пользователь оформил подписку, вы храните дату окончания и каждый день проверяете, не пора ли закрыть доступ. Код на первый взгляд рабочий, тесты локально проходят. А потом клиент из Владивостока пишет, что доступ пропал на несколько часов раньше срока, а клиент из другого таймзоны — что подписка «продлилась сама». Оба бага живут в одном месте: неаккуратная работа с датами. Разберём на сквозном примере, где именно ломается и как чинить. Всё — для PHP 8.x.
Задача и данные
У нас есть подписка. В базе (PostgreSQL 16, но это не принципиально) хранится момент окончания в колонке expires_at типа timestamptz. Приложение работает на сервере с системным временем UTC, а пользователи — в разных часовых поясах. Нужно:
- посчитать дату окончания при оплате: сейчас плюс 30 дней;
- проверять при каждом запросе, активна ли подписка;
- показывать пользователю дату окончания в его локальном времени.
Звучит на три строки. На практике почти каждый пункт — отдельная ловушка.
Ловушка первая: строки вместо объектов
Первое, что делает почти каждый новичок, — считает даты арифметикой над таймстампами или строками.
// Как делать НЕ надо
$expires = date('Y-m-d H:i:s', time() + 30 * 24 * 60 * 60);
Здесь два скрытых бага. 30 * 24 * 60 * 60 — это ровно 30 суток по 86400 секунд, но реальные сутки не всегда 86400 секунд: при переходе на летнее/зимнее время (в тех странах, где он есть) день длится 23 или 25 часов. Второй баг тоньше: date() использует часовой пояс из date.timezone в php.ini или из date_default_timezone_set(), и вы часто не знаете, какой он на конкретном сервере. Получаете строку без пояса и теряете информацию о том, к какому моменту она относится.
Правильный инструмент — классы DateTimeImmutable и DateInterval.
<?php
// PHP 8.x
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$expires = $now->add(new DateInterval('P30D'));
// В базу пишем в UTC в формате ISO 8601
echo $expires->format(DateTimeInterface::ATOM);
// 2025-06-14T09:30:00+00:00
P30D в DateInterval — это «период 30 дней» по стандарту ISO 8601 1. При сложении через add() PHP учитывает календарь, а не просто прибавляет секунды. Именно поэтому нельзя заменять его на арифметику таймстампов.
Ловушка вторая: мутабельность DateTime
В PHP два почти одинаковых класса: DateTime и DateTimeImmutable. Разница в одном слове, а последствия огромные. Методы modify(), add(), sub() у DateTime меняют сам объект и возвращают его же. У DateTimeImmutable — возвращают новый объект, а исходный остаётся прежним.
<?php
// Мутабельный вариант — источник трудноуловимых багов
$start = new DateTime('2025-01-01 00:00:00');
$end = $start;
$end->add(new DateInterval('P30D'));
echo $start->format('Y-m-d'); // 2025-01-31 — start тоже изменился!
echo $end->format('Y-m-d'); // 2025-01-31
Здесь $start и $end — ссылки на один объект. Вы прибавили интервал к $end, а «уехал» и $start. В подписочной логике это выглядит так: считаете дату окончания на базе даты начала, а дата начала после расчёта оказывается равной дате окончания. Дальше любые проверки врут.
<?php
// Иммутабельный вариант ведёт себя предсказуемо
$start = new DateTimeImmutable('2025-01-01 00:00:00');
$end = $start->add(new DateInterval('P30D'));
echo $start->format('Y-m-d'); // 2025-01-01 — не тронут
echo $end->format('Y-m-d'); // 2025-01-31
По умолчанию беритеDateTimeImmutable. МутабельныйDateTimeоставьте для случаев, когда вам действительно нужно менять объект на месте, — а таких случаев в бизнес-логике почти не бывает.
Ловушка третья: часовые пояса при сравнении
Теперь проверка активности. Из базы приходит строка вроде 2025-06-14 09:30:00+00. Сравниваем с «сейчас».
<?php
$expiresRaw = '2025-06-14 09:30:00+00'; // из базы
$expires = new DateTimeImmutable($expiresRaw);
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$isActive = $expires > $now;
Хорошая новость: объекты DateTimeImmutable сравниваются операторами >, <, == корректно — PHP сравнивает абсолютные моменты времени, а не строки. То есть 09:30 UTC и 12:30 +03:00 будут признаны равными, потому что это один и тот же момент.
Плохая новость: это работает, только если у обоих объектов известен пояс. Если строка из базы пришла без указания пояса, PHP подставит пояс по умолчанию из настроек приложения — и на сервере в UTC вы получите один результат, а на машине разработчика в другом поясе — другой. Отсюда «подписка истекла на три часа раньше».
Правило
Храните и сравнивайте всё в UTC. Пояс пользователя применяйте только на самом краю — при выводе.
<?php
// На вывод — переводим в пояс пользователя
$userTz = new DateTimeZone('Asia/Vladivostok');
$expiresLocal = $expires->setTimezone($userTz);
echo $expiresLocal->format('d.m.Y H:i'); // 14.06.2025 19:30
setTimezone() не меняет момент времени, только представление. Момент остаётся тем же, меняется лишь то, какими цифрами вы его показываете.
Ловушка четвёртая: разница дат и знак
Часто нужно показать «осталось N дней». Здесь используется diff(), который возвращает DateInterval.
<?php
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$diff = $now->diff($expires);
// $diff->days — всегда положительное число дней
// $diff->invert — 1, если $expires в прошлом, иначе 0
$daysLeft = $diff->invert === 1 ? -$diff->days : $diff->days;
echo $daysLeft; // например, 12 или -3
Ключевой момент: поле days у результата diff() всегда неотрицательное. Направление хранится отдельно, в поле invert. Если про это забыть и просто взять $diff->days, «истекла 3 дня назад» превратится в «осталось 3 дня». Отдельно не путайте $diff->days (общее число суток за весь период) с $diff->d (остаток дней в разбивке месяцы/дни).
Сравнение подходов
| Что нужно | Чем делать | Чем НЕ делать |
|---|---|---|
| Прибавить период (дни, месяцы) | DateTimeImmutable::add() + DateInterval | Арифметика над time() |
| Хранить объект в бизнес-логике | DateTimeImmutable | DateTime (мутабелен) |
| Сравнить два момента | Операторы > < на объектах в известных поясах | Сравнение строк format() |
| Разница между датами | diff(), учитывая invert | Вычитание таймстампов вручную |
| Показать в поясе пользователя | setTimezone() на выводе | Хранение в локальном поясе |
Собираем рабочий класс
<?php
// PHP 8.x
final class Subscription
{
public function __construct(
private readonly DateTimeImmutable $expiresAt // всегда UTC
) {}
public static function startingNow(int $days): self
{
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
return new self($now->add(new DateInterval("P{$days}D")));
}
public function isActive(?DateTimeImmutable $at = null): bool
{
$at ??= new DateTimeImmutable('now', new DateTimeZone('UTC'));
return $this->expiresAt > $at;
}
public function daysLeft(?DateTimeImmutable $at = null): int
{
$at ??= new DateTimeImmutable('now', new DateTimeZone('UTC'));
$diff = $at->diff($this->expiresAt);
return $diff->invert === 1 ? -$diff->days : $diff->days;
}
public function formatFor(DateTimeZone $tz, string $format = 'd.m.Y H:i'): string
{
return $this->expiresAt->setTimezone($tz)->format($format);
}
}
Момент «сейчас» здесь передаётся параметром с дефолтом. Это делает класс тестируемым: в тесте вы подставите фиксированную дату вместо реального времени и проверите поведение на границе — за секунду до истечения и через секунду после.
Что ломается на границах
- Прибавление месяцев.
add(new DateInterval('P1M'))к 31 января даёт 3 марта, а не «конец февраля»: PHP переносит лишние дни. Если нужен «последний день следующего месяца» — считайте явно, не черезP1M. - Переход на летнее время. В поясах с DST прибавление
P1Dдаёт другой день по календарю, но не ровно 24 часа. Если вам нужны именно 24 часа — используйтеPT24H, а неP1D. Это разные вещи. - Невалидные строки. Конструктор
DateTimeImmutableпри кривом вводе бросаетException, а не молча возвращает мусор. Оборачивайте разбор пользовательского ввода в try/catch и не доверяйте формату. - Секунды и микросекунды. При сравнении «на равенство» два момента с разницей в микросекунды не равны. Для сравнения с точностью до секунды приведите оба к началу секунды через
setTime()или сравнивайте поgetTimestamp().
Что настроить дальше
Зафиксируйте date.timezone = UTC в конфигурации PHP явно, чтобы поведение не зависело от системного времени сервера. Договоритесь на уровне проекта, что в бизнес-коде используется только DateTimeImmutable — это можно проверять статическим анализатором. Для сложных календарных расчётов (рабочие дни, повторяющиеся события, парсинг «человеческих» строк) посмотрите библиотеку Carbon: она построена поверх стандартных классов PHP и добавляет удобный API, но те же ловушки с поясами и мутабельностью в ней никуда не деваются — знать их всё равно придётся.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: PHP Manual: Класс DateTimeImmutable, PHP Manual: DateInterval и форматы ISO 8601
Частые вопросы
А если у меня PHP 7.x, всё это работает?
Почему сравнение объектов дат оператором больше/меньше вообще работает?
Что выбрать: хранить таймстамп int или строку с датой?
Не сработало: даты сдвигаются на несколько часов. В чём дело?
Стоит ли сразу подключать Carbon вместо стандартных классов?
Как тестировать код с датами, если время всё время идёт?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.