Работа с датами — область, где код выглядит правильным, проходит ревью и ломается через полгода: в другом часовом поясе, в високосный год или тридцать первого числа. Причина в том, что «дата» — понятие сложнее, чем кажется, и большинство ошибок происходит из-за смешения трёх разных вещей: момента времени, календарной даты и локального времени.
Разберём API и, что важнее, три ловушки, в которые попадают чаще всего.
Начните с DateTimeImmutable, а не с DateTime. Совет обычно дают в конце статьи как улучшение, но правильнее сделать это выбором по умолчанию.
Причина в том, что DateTime изменяет сам себя: $date->modify('+1 day') меняет исходный объект. Передали дату в функцию, та внутри что-то посчитала — и у вызывающего кода дата изменилась. Такие ошибки крайне неприятны: они не воспроизводятся в тестах отдельных функций и проявляются только в сочетании вызовов.
DateTimeImmutable возвращает новый объект и оставляет исходный нетронутым. Интерфейс тот же, поводов использовать изменяемый вариант в новом коде практически нет.
Основы DateTime
Создание объекта даты
<?php
// Текущая дата и время
$now = new DateTime();
// Из строки
$date = new DateTime('2026-05-28');
$date = new DateTime('28.05.2026'); // Российский формат работает
$date = new DateTime('May 28, 2026');
$date = new DateTime('next Monday');
$date = new DateTime('+1 week');
$date = new DateTime('first day of next month');
// Из timestamp
$date = new DateTime('@1716854400');
// Иммутабельная версия (рекомендуется)
$date = new DateTimeImmutable('2026-05-28');Форматирование
<?php
$date = new DateTime('2026-05-28 14:30:00');
// Стандартные форматы
echo $date->format('Y-m-d'); // 2026-05-28
echo $date->format('d.m.Y'); // 28.05.2026
echo $date->format('H:i:s'); // 14:30:00
echo $date->format('d.m.Y H:i'); // 28.05.2026 14:30
// ISO 8601
echo $date->format(DateTime::ATOM); // 2026-05-28T14:30:00+00:00
// Для API
echo $date->format('c'); // ISO 8601
echo $date->format('r'); // RFC 2822Таблица символов форматирования
| Символ | Описание | Пример |
|---|---|---|
Y | Год, 4 цифры | 2026 |
y | Год, 2 цифры | 26 |
m | Месяц с ведущим нулём | 05 |
n | Месяц без нуля | 5 |
d | День с ведущим нулём | 28 |
j | День без нуля | 28 |
H | Часы (24ч) | 14 |
i | Минуты | 30 |
s | Секунды | 00 |
D | День недели (3 буквы) | Thu |
l | День недели (полный) | Thursday |
N | День недели (1-7) | 4 |
F | Месяц (полный) | May |
M | Месяц (3 буквы) | May |
U | Unix timestamp | 1716900600 |
Арифметика дат
Добавление и вычитание
<?php
$date = new DateTime('2026-05-28');
// С modify()
$date->modify('+1 day');
$date->modify('-2 weeks');
$date->modify('+3 months');
$date->modify('next Friday');
$date->modify('last day of this month');
// С DateInterval (предпочтительно)
$date = new DateTime('2026-05-28');
$date->add(new DateInterval('P1D')); // +1 день
$date->add(new DateInterval('P2W')); // +2 недели
$date->sub(new DateInterval('P3M')); // -3 месяца
$date->add(new DateInterval('PT2H30M')); // +2 часа 30 минутФормат DateInterval
P - начало (Period)
nY - годы
nM - месяцы
nD - дни
T - разделитель времени
nH - часы
nM - минуты (после T!)
nS - секунды
Примеры:
P1Y = 1 год
P2M = 2 месяца
P10D = 10 дней
PT1H = 1 час
PT30M = 30 минут
P1DT12H = 1 день 12 часов
P1Y2M3DT4H5M6S = 1 год 2 месяца 3 дня 4 часа 5 минут 6 секундРазность между датами
<?php
$start = new DateTime('2026-01-01');
$end = new DateTime('2026-05-28');
$diff = $start->diff($end);
echo $diff->y; // Полных лет: 0
echo $diff->m; // Полных месяцев: 4
echo $diff->d; // Дней: 27
echo $diff->days; // Всего дней: 147
echo $diff->invert; // 0 = положительная разница, 1 = отрицательная
// Форматирование разницы
echo $diff->format('%y лет %m месяцев %d дней');
echo $diff->format('%a дней'); // Всего днейРазбиение периода на интервалы
<?php
$start = new DateTime('2026-01-01');
$end = new DateTime('2026-12-31');
$interval = new DateInterval('P1M'); // По месяцам
$period = new DatePeriod($start, $interval, $end);
foreach ($period as $date) {
echo $date->format('Y-m-d') . "\n";
}
// 2026-01-01
// 2026-02-01
// 2026-03-01
// ... и т.д.Разбиение на равные части
<?php
function splitPeriod(DateTime $start, DateTime $end, int $parts): array
{
$totalSeconds = $end->getTimestamp() - $start->getTimestamp();
$partSeconds = (int) ($totalSeconds / $parts);
$dates = [$start->format('Y-m-d H:i:s')];
$current = clone $start;
for ($i = 1; $i < $parts; $i++) {
$current->modify("+{$partSeconds} seconds");
$dates[] = $current->format('Y-m-d H:i:s');
}
$dates[] = $end->format('Y-m-d H:i:s');
return $dates;
}
// Делим квартал на 3 части
$result = splitPeriod(
new DateTime('2026-01-01'),
new DateTime('2026-03-31'),
3
);Разбиение по дням
<?php
$start = new DateTime($_GET['start'] ?? 'first day of this month');
$end = new DateTime($_GET['end'] ?? 'last day of this month');
// Добавляем время до конца дня
$end->setTime(23, 59, 59);
$interval = new DateInterval('P30D');
$period = new DatePeriod($start, $interval, $end);
$ranges = [];
$dates = iterator_to_array($period);
$dates[] = $end; // Добавляем конечную дату
for ($i = 0; $i < count($dates) - 1; $i++) {
$ranges[] = [
'from' => $dates[$i]->format('Y-m-d'),
'to' => $dates[$i + 1]->format('Y-m-d'),
];
}
print_r($ranges);Часовые пояса
<?php
// Установка часового пояса
date_default_timezone_set('Europe/Moscow');
// Создание с часовым поясом
$moscowTime = new DateTime('now', new DateTimeZone('Europe/Moscow'));
$utcTime = new DateTime('now', new DateTimeZone('UTC'));
echo $moscowTime->format('Y-m-d H:i:s T'); // 2026-05-28 17:30:00 MSK
echo $utcTime->format('Y-m-d H:i:s T'); // 2026-05-28 14:30:00 UTC
// Конвертация часовых поясов
$date = new DateTime('2026-05-28 12:00:00', new DateTimeZone('UTC'));
$date->setTimezone(new DateTimeZone('Europe/Moscow'));
echo $date->format('H:i'); // 15:00 (UTC+3)Сравнение дат
<?php
$date1 = new DateTime('2026-05-28');
$date2 = new DateTime('2026-06-15');
// Операторы сравнения
if ($date1 < $date2) {
echo 'date1 раньше date2';
}
if ($date1 == $date2) {
echo 'даты равны';
}
// Проверка попадания в диапазон
$checkDate = new DateTime('2026-06-01');
$start = new DateTime('2026-05-01');
$end = new DateTime('2026-06-30');
if ($checkDate >= $start && $checkDate <= $end) {
echo 'Дата в диапазоне';
}Практические примеры
Возраст по дате рождения
<?php
function getAge(string $birthdate): int
{
$birth = new DateTime($birthdate);
$now = new DateTime();
return $now->diff($birth)->y;
}
echo getAge('1990-05-15'); // 36Рабочие дни между датами
<?php
function getWorkingDays(DateTime $start, DateTime $end): int
{
$workingDays = 0;
$current = clone $start;
while ($current <= $end) {
$dayOfWeek = (int) $current->format('N');
if ($dayOfWeek < 6) { // Пн-Пт
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
echo getWorkingDays(
new DateTime('2026-05-01'),
new DateTime('2026-05-31')
); // 21Русские названия месяцев
<?php
function formatDateRu(DateTime $date): string
{
$months = [
1 => 'января', 2 => 'февраля', 3 => 'марта',
4 => 'апреля', 5 => 'мая', 6 => 'июня',
7 => 'июля', 8 => 'августа', 9 => 'сентября',
10 => 'октября', 11 => 'ноября', 12 => 'декабря',
];
return $date->format('j') . ' ' . $months[(int)$date->format('n')] . ' ' . $date->format('Y');
}
echo formatDateRu(new DateTime()); // 28 мая 2026Год в футере
<!-- Простой вывод -->
© <?= date('Y') ?> Все права защищены
<!-- С диапазоном -->
© <?= (date('Y') > 2024 ? '2024–' : '') . date('Y') ?> КомпанияТри ловушки, о которых стоит знать заранее
Сложение месяцев не делает того, что вы ожидаете. 31 января + 1 месяц в PHP даёт 3 марта (или 2 марта в високосный год): PHP прибавляет единицу к номеру месяца и нормализует несуществующее «31 февраля». Формально верно, практически — почти всегда не то, что имелось в виду. Если нужен «тот же день следующего месяца с ограничением по длине месяца», это отдельная логика, а не modify('+1 month'). Проверять такой код нужно именно на 29, 30 и 31 числах — на пятнадцатом всё работает.
Разность дат считается не в тех единицах, что кажется. DateInterval, полученный из diff(), содержит разложение по годам, месяцам и дням, а не общее количество. Свойство days даёт полное число дней, а d — остаток после вычета месяцев, и путаница между ними — классическая ошибка. Для «сколько прошло дней» берите days, а не d.
Часовой пояс — часть значения, а не оформление. Дата без указания пояса создаётся в поясе, заданном настройками PHP, который на сервере может отличаться от вашего и от пояса пользователя. Отсюда правило, снимающее большинство проблем: храните и считайте в UTC, переводите в местное время только при выводе. Сравнение двух дат в разных поясах работает корректно (PHP приводит их к одному моменту), а вот форматирование — нет, и «отчёт за сутки» легко оказывается сдвинутым на несколько часов.
Итоги
DateTimeImmutable по умолчанию. Изменяемый вариант нужен редко и создаёт неочевидные ошибки.
Храните в UTC, показывайте в местном. Одно это правило снимает большую часть проблем с датами в проектах, где есть пользователи из разных регионов.
Проверяйте граничные даты. Конец месяца, високосный год, переход на летнее время в тех странах, где он есть. Логика с датами, протестированная на середине месяца, ничего не гарантирует.
Разделяйте момент времени и календарную дату. «Заказ создан» — это момент (нужен пояс), «дата рождения» — календарная дата (пояс не нужен и только мешает). Хранение второго как момента времени приводит к тому, что при переводе поясов день рождения сдвигается на сутки.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.