Дата и время: момент, зона и строка — это разные вещи
В 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по контексту]DateTimeImmutable и DateTimeZone
DateTimeZone задаёт правила локального времени: offset от UTC, переходы на летнее время, исторические изменения. Поэтому лучше использовать именованные зоны вроде Europe/Berlin, Asia/Almaty, America/New_York, а не голый +02:00, если речь о будущем расписании. Offset говорит только «сейчас плюс два часа», но не знает, что будет после перехода DST или изменения правил страны.
<?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, сериализация и форматы данных.
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 полезен, но не должен вытеснять смысловые типы: «дата рождения», «рабочий день», «дедлайн в зоне пользователя» — это не одно и то же.
Форматирование и парсинг
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 календарных дней».
<?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.
См. также
- JSON, сериализация и форматы данных — как отдавать даты в API и не терять timezone.
- Ошибки, исключения и Throwable — обработка ошибок парсинга,
InvalidArgumentExceptionи строгие границы данных. - GET, POST и фильтрация ввода — проверка дат, пришедших из формы или query string.
- HTTP-заголовки, ответы и редиректы — даты в заголовках, кешировании и ответах сервера.
- SPL, итераторы и коллекции — итерация по периодам и ленивые последовательности.
- Строки, UTF-8 и mbstring — почему форматированная дата остаётся строкой с кодировкой и правилами вывода.