Дата и время: момент, зона и строка — это разные вещи

В PHP дата и время обычно живут в объектах DateTime и DateTimeImmutable. Оба представляют дату-время с часовым поясом, но ведут себя по-разному при изменениях: DateTime меняет сам объект, а DateTimeImmutable возвращает новый. В прикладном коде чаще безопаснее начинать с immutable-варианта: меньше шансов случайно сдвинуть дату, которую использует другой кусок программы.

<?php

$tz = new DateTimeZone('Europe/Berlin');
$start = new DateTimeImmutable('2026-03-28 10:00:00', $tz);
$next = $start->modify('+1 day');

echo $start->format(DateTimeInterface::ATOM) . PHP_EOL;
echo $next->format(DateTimeInterface::ATOM) . PHP_EOL;

Типичная рабочая модель такая: внутри системы хранить момент времени однозначно, обычно в UTC или с явным offset, а для пользователя форматировать в его timezone. Это похоже на правило из Строки, UTF-8 и mbstring: байты, кодировка и отображение нельзя смешивать в одну кашу. С датами то же самое: timestamp, timezone и человекочитаемая строка — разные уровни.

flowchart LR A[Момент времени\ninstant] --> B[UTC / timestamp\nдля хранения и сравнения] A --> C[Timezone\nправила локального времени] C --> D[Локальное представление\n2026-06-15 18:40] D --> E[Форматированная строка\nдля HTML, JSON, логов] E --> F[Экранирование или сериализация\nпо контексту]
flowchart LR
    A[Момент времени\ninstant] --> B[UTC / timestamp\nдля хранения и сравнения]
    A --> C[Timezone\nправила локального времени]
    C --> D[Локальное представление\n2026-06-15 18:40]
    D --> E[Форматированная строка\nдля HTML, JSON, логов]
    E --> F[Экранирование или сериализация\nпо контексту]
Одна и та же дата проходит несколько уровней: момент времени, timezone, локальное представление и строковый формат.
Quick recall
Почему в прикладном PHP-коде часто безопаснее начинать с `DateTimeImmutable`, а не с `DateTime`?

DateTimeImmutable и DateTimeZone

DateTimeZone задаёт правила локального времени: offset от UTC, переходы на летнее время, исторические изменения. Поэтому лучше использовать именованные зоны вроде Europe/Berlin, Asia/Almaty, America/New_York, а не голый +02:00, если речь о будущем расписании. Offset говорит только «сейчас плюс два часа», но не знает, что будет после перехода DST или изменения правил страны.

Карта часовых поясов помогает увидеть, почему именованная зона описывает не только числовой offset, но и реальные региональные правила.Source: commons.wikimedia.org
<?php

$nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$forUser = $nowUtc->setTimezone(new DateTimeZone('Europe/Berlin'));

echo $nowUtc->format(DateTimeInterface::RFC3339) . PHP_EOL;
echo $forUser->format('d.m.Y H:i T') . PHP_EOL;

setTimezone() не меняет сам момент времени; он меняет способ показать этот момент в другой зоне. Если было «один и тот же instant», он им и остаётся. Это критично для логов, платежей, дедлайнов и API, где потом данные уйдут в JSON, сериализация и форматы данных.

Quick recall
Что концептуально происходит при вызове `setTimezone()` у объекта даты-времени?

Timestamp — не «дата», а число секунд

Unix timestamp — количество секунд с 1970-01-01 00:00:00 UTC, без leap seconds в обычной модели PHP. Это удобный машинный формат для сравнения моментов, TTL, сортировки, очередей и логов. Но timestamp сам по себе не содержит timezone и не говорит, как дату увидит пользователь.

<?php

$createdAt = new DateTimeImmutable('2026-06-15 12:30:00', new DateTimeZone('UTC'));

$payload = [
    'created_at' => $createdAt->format(DateTimeInterface::RFC3339_EXTENDED),
    'created_at_ts' => $createdAt->getTimestamp(),
];

Для публичного API чаще удобнее RFC 3339-строка: она читаемая и несёт offset. Для внутренней арифметики timestamp полезен, но не должен вытеснять смысловые типы: «дата рождения», «рабочий день», «дедлайн в зоне пользователя» — это не одно и то же.

Quick recall
Что потеряно, если в системе остался только Unix timestamp без timezone и исходного смысла поля?

Форматирование и парсинг

format() использует собственные PHP-токены: Y — год из четырёх цифр, m — месяц, d — день, H:i:s — время в 24-часовом формате, P — offset вида +02:00. Для машинных форматов лучше использовать константы DateTimeInterface::RFC3339, RFC3339_EXTENDED, ATOM, чем руками собирать почти-ISO строку.

<?php

$dt = new DateTimeImmutable('2026-06-15 09:05:07', new DateTimeZone('UTC'));

echo $dt->format('Y-m-d H:i:s') . PHP_EOL;           // 2026-06-15 09:05:07
echo $dt->format(DateTimeInterface::RFC3339) . PHP_EOL; // 2026-06-15T09:05:07+00:00

Для разбора строки с известным форматом используйте DateTimeImmutable::createFromFormat(), а не свободный парсер конструктора. Конструктор удобен для now, tomorrow, first day of next month, но для пользовательского ввода он слишком терпимый.

<?php

$input = '15.06.2026 18:40';
$tz = new DateTimeZone('Europe/Berlin');

$dt = DateTimeImmutable::createFromFormat('!d.m.Y H:i', $input, $tz);
$errors = DateTimeImmutable::getLastErrors();

if ($dt === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
    throw new InvalidArgumentException('Некорректная дата');
}

echo $dt->format(DateTimeInterface::RFC3339);

! в начале формата сбрасывает непереданные поля к Unix epoch, а не берёт текущие секунды и текущую дату. Это полезно, когда вы хотите предсказуемый parse. Без проверки getLastErrors() можно пропустить странные случаи: PHP умеет нормализовать «лишние» дни и месяцы, а это не всегда то, что нужно форме ввода. Валидация пользовательских полей тесно связана с GET, POST и фильтрация ввода.

Интервалы, периоды и календарная арифметика

DateInterval описывает длительность или календарный сдвиг: P1D — один день, P1M — один месяц, PT2H — два часа. Важно: «месяц» не равен фиксированному числу секунд. После 31 января +1 month может дать результат, который не совпадает с ожиданием бизнес-правила. Для платежей, подписок и расписаний правило надо формулировать явно: «последний день месяца», «то же число, если оно существует», «30 календарных дней».

Календарное сравнение показывает, почему прибавление одного месяца к 31 января требует явного бизнес-правила.
<?php

$start = new DateTimeImmutable('2026-01-31', new DateTimeZone('UTC'));

echo $start->add(new DateInterval('P1M'))->format('Y-m-d') . PHP_EOL;
echo $start->modify('last day of next month')->format('Y-m-d') . PHP_EOL;

DatePeriod даёт повторяющуюся последовательность дат. Он удобен для календарей, отчётов по дням и генерации слотов.

<?php

$period = new DatePeriod(
    new DateTimeImmutable('2026-06-01', new DateTimeZone('UTC')),
    new DateInterval('P1D'),
    new DateTimeImmutable('2026-06-04', new DateTimeZone('UTC'))
);

foreach ($period as $day) {
    echo $day->format('Y-m-d') . PHP_EOL;
}

DatePeriod итерируется как последовательность, поэтому по духу он ближе к теме SPL, итераторы и коллекции, чем к обычному массиву из Массивы как ordered map. Это особенно приятно для больших диапазонов: не нужно заранее строить огромный array.

Локаль: format() не переводит месяцы на русский

DateTimeInterface::format() не является локализатором. Если написать format('F'), вы получите английское имя месяца, потому что это форматирование PHP, а не ICU-локаль. Для русских дат в интерфейсе используйте расширение intl и IntlDateFormatter.

<?php

$dt = new DateTimeImmutable('2026-06-15 18:40:00', new DateTimeZone('Europe/Berlin'));

$fmt = new IntlDateFormatter(
    'ru_RU',
    IntlDateFormatter::LONG,
    IntlDateFormatter::SHORT,
    'Europe/Berlin',
    IntlDateFormatter::GREGORIAN
);

echo $fmt->format($dt); // например: 15 июня 2026 г. в 18:40

Для HTML-вывода локализованная строка всё равно остаётся пользовательским текстом: её надо вставлять в шаблон с нормальным экранированием, как описано в XSS, экранирование вывода и шаблоны.

Частые ловушки

Первая ловушка — хранить локальную строку вроде 15.06.2026 18:40 как единственную правду. Без timezone и формата эта строка плохо переносится между API, базой и логами.

Вторая — использовать timezone сервера как бизнес-правило. Сервер может переехать, Docker-образ может иметь другой date.timezone, а пользователь живёт в своей зоне. Задавайте timezone явно.

Третья — считать +1 month универсальной операцией для подписок. Календарная арифметика требует продуктового правила.

Четвёртая — забывать про DST. Если задача звучит как «каждый день в 09:00 по Берлину», работайте с локальной датой и Europe/Berlin. Если задача звучит как «через 3600 секунд», считайте elapsed time, обычно в UTC.

Пятая — путать машинный формат и локализованный интерфейс. В JSON отдавайте RFC 3339 или другой явно оговорённый формат; пользователю показывайте локализованную строку через IntlDateFormatter.

См. также

Sources

  1. PHP Manual: DateTimeImmutable
  2. PHP Manual: DateTimeZone
  3. PHP Manual: DateInterval
  4. PHP Manual: DatePeriod
  5. PHP Manual: DateTimeInterface::format
  6. PHP Manual: DateTimeImmutable::createFromFormat
  7. PHP Manual: IntlDateFormatter
  8. PHP Manual: Supported Date and Time Formats