Bitrix D7 Type\DateTime: даты, часовые пояса, ORM и типичные ошибки

В Битрикс даты разъезжаются быстрее, чем кажется: MySQL хранит DATETIME без таймзоны, PHP смотрит на date.timezone, сайт — на настройки «Региональные настройки», а ORM ждёт объекты Bitrix\Main\Type\DateTime, а не строки. Если в интеграции «вчера» и «сегодня» прыгают на час — почти всегда виноват этот зоопарк.

Ниже — практический минимум по Bitrix\Main\Type\Date и Bitrix\Main\Type\DateTime: создание, форматы, часовые пояса, ORM, агенты и типичные ошибки на проде. Связанные темы: ORM D7, агенты, ServiceLocator.

Date vs DateTime: что брать

Bitrix\Main\Type\Date — календарная дата без времени (день рождения, дата доставки «только день»). Bitrix\Main\Type\DateTime — дата+время с привязкой к часовому поясу PHP/сайта. Для полей заказа, логов, дедлайнов и «обновлено в» почти всегда нужен DateTime.

<?php
use Bitrix\Main\Type\Date;
use Bitrix\Main\Type\DateTime;

$day = new Date('08.09.2026', 'd.m.Y');
$now = new DateTime(); // сейчас в таймзоне сайта/PHP
$fromIso = new DateTime('2026-09-08T14:30:00', 'Y-m-d\TH:i:s');

Конструктор: new DateTime($time = 'now', $format = null, \DateTimeZone $timezone = null). Если $format не задан, строка парсится как для нативного \DateTime (относительные фразы вроде +1 day тоже работают).

Форматирование и разбор строк

В шаблонах и API не клеите дату через date() от unix-timestamp из разных источников — держите объект до последнего шага:

<?php
use Bitrix\Main\Type\DateTime;

$dt = new DateTime('08.09.2026 17:45:00', 'd.m.Y H:i:s');

echo $dt->format('Y-m-d H:i:s'); // 2026-09-08 17:45:00
echo $dt->format('d.m.Y');       // 08.09.2026

// для вывода пользователю удобны Culture-форматы сайта
echo $dt->toString(); // зависит от формата культуры

Разбор «грязного» ввода из формы:

<?php
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\Context;

$raw = (string)Context::getCurrent()->getRequest()->getPost('deadline');
try {
    // явный формат из UI — меньше сюрпризов, чем strtotime
    $deadline = new DateTime($raw, 'd.m.Y H:i');
} catch (\Throwable $e) {
    throw new \InvalidArgumentException('Некорректная дата: ' . $raw);
}

Если формат неизвестен заранее, сначала нормализуйте на своей стороне (один формат в API), а не надейтесь на «умный» парсер.

Часовые пояса: сайт, PHP, MySQL

Три слоя, которые должны совпадать по смыслу:

  • PHPdate.timezone в php.ini / пуле FPM;
  • Битрикс — настройки сайта (часовой пояс) и культура;
  • MySQLtime_zone сессии и тип колонки (DATETIME vs TIMESTAMP).
<?php
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\Config\Option;

// текущий пояс PHP
$phpTz = date_default_timezone_get();

// явно в Europe/Moscow
$msk = new DateTime('now', null, new \DateTimeZone('Europe/Moscow'));

// сдвиг относительно UTC для API
$utc = clone $msk;
$utc->setTimeZone(new \DateTimeZone('UTC'));
$payload = [
    'event_at' => $utc->format('Y-m-d\TH:i:s\Z'),
];

Правило для интеграций: внутри Битрикс храните и считайте в таймзоне сайта, наружу (вебхуки, REST партнёра) отдавайте ISO-8601 в UTC или с явным offset. Не смешивайте unix-timestamp из JS (Date.now()) с «наивным» DATETIME без пометки пояса.

Арифметика: плюс день, минус час, конец суток

<?php
use Bitrix\Main\Type\DateTime;

$dt = new DateTime();

$dt->add('1 day');           // +1 сутки
$dt->add('-2 hours');        // -2 часа
$dt->add(new \DateInterval('P7D')); // +7 дней через DateInterval

// границы дня — частый кейс для отчётов
$from = new DateTime('today');
$to = new DateTime('today');
$to->add('1 day');
$to->add('-1 second'); // 23:59:59 текущего дня

// или явнее
$start = DateTime::createFromPhp(new \DateTime('today 00:00:00'));
$end   = DateTime::createFromPhp(new \DateTime('today 23:59:59'));

add() меняет объект на месте (как и многие методы D7). Если нужна «копия до сдвига» — клонируйте: $copy = clone $dt;.

Сравнение и «раньше / позже»

<?php
use Bitrix\Main\Type\DateTime;

$a = new DateTime('2026-09-08 10:00:00', 'Y-m-d H:i:s');
$b = new DateTime('2026-09-08 12:00:00', 'Y-m-d H:i:s');

if ($a->getTimestamp() < $b->getTimestamp()) {
    // a раньше b
}

// удобные хелперы культуры/формата — для UI; для логики лучше timestamp или diff
$diffSeconds = $b->getTimestamp() - $a->getTimestamp(); // 7200

Не сравнивайте через == объекты и не сравнивайте отформатированные строки d.m.Y — это ломается на ведущих нулях и разных культурах. Для фильтров ORM используйте объекты DateTime целиком.

ORM: запись и выборка по дате

В DataManager-полях типа datetime / date ORM ждёт (и отдаёт) объекты Type\DateTime / Type\Date:

<?php
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\ORM\Query\Query;
// пример: своя таблица модуля
use Local\Exchange\OrderTable;

$now = new DateTime();

$result = OrderTable::add([
    'UF_EXTERNAL_ID' => 'EXT-100500',
    'UF_SYNCED_AT' => $now,
]);

// фильтр «за последние 24 часа»
$from = new DateTime();
$from->add('-1 day');

$rows = OrderTable::getList([
    'filter' => [
        '>=UF_SYNCED_AT' => $from,
        '<=UF_SYNCED_AT' => new DateTime(),
    ],
    'order' => ['UF_SYNCED_AT' => 'DESC'],
    'limit' => 100,
])->fetchAll();

foreach ($rows as $row) {
    /** @var DateTime|null $synced */
    $synced = $row['UF_SYNCED_AT'];
    if ($synced instanceof DateTime) {
        echo $synced->format('Y-m-d H:i:s');
    }
}

Передавать в фильтр сырую строку '08.09.2026' иногда «прокатывает», но поведение зависит от культуры и драйвера. Явный DateTime — предсказуемее, особенно в агентах без хита пользователя.

Инфоблоки и свойства типа «Дата/Время»

В старом API и в D7-обёртках инфоблоков даты часто приходят строкой в формате сайта. Нормализуйте на входе сервиса:

<?php
use Bitrix\Main\Type\DateTime;
use Bitrix\Iblock\ElementTable;

// если пришла строка из GetList / CIBlockElement
function parseBitrixDate(?string $value): ?DateTime
{
    if ($value === null || $value === '') {
        return null;
    }
    // типичные форматы админки
    foreach (['d.m.Y H:i:s', 'd.m.Y H:i', 'd.m.Y', 'Y-m-d H:i:s', 'Y-m-d'] as $fmt) {
        $dt = DateTime::tryParse($value, $fmt);
        if ($dt instanceof DateTime) {
            return $dt;
        }
    }
    return null;
}

Если в вашей версии ядра нет tryParse, оберните new DateTime(...) в try/catch — смысл тот же: один хелпер на модуль, без копипасты форматов по компонентам.

Агенты и cron: «сейчас» без сюрпризов

Агент крутится в CLI/cron-контексте. Таймзона должна быть той же, что у веб-пула, иначе ночной отчёт «за вчера» уедет на час:

<?php
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\Config\Option;

function local_exchange_agent(): string
{
    // на всякий случай фиксируем пояс явно в коде агента
    $tzName = Option::get('main', 'default_time_zone') ?: 'Europe/Moscow';
    if (@timezone_open($tzName)) {
        date_default_timezone_set($tzName);
    }

    $from = new DateTime('today');
    $to = new DateTime('tomorrow');

    // ... выборка и обмен ...

    return 'local_exchange_agent();';
}

Проверьте php -i | grep date.timezone на том же PHP binary, которым крутится cron Битрикс. Расхождение FPM vs CLI — классика BitrixVM.

Связка с HttpClient и внешними API

Во внешний мир лучше отдавать UTC. Пример рядом с HttpClient:

<?php
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Json;

$local = new DateTime(); // таймзона сайта
$utc = clone $local;
$utc->setTimeZone(new \DateTimeZone('UTC'));

$http = new HttpClient([
    'socketTimeout' => 5,
    'streamTimeout' => 15,
]);
$http->setHeader('Content-Type', 'application/json');

$http->post('https://api.example/v1/events', Json::encode([
    'occurred_at' => $utc->format('Y-m-d\TH:i:s\Z'),
    'local_at' => $local->format('Y-m-d H:i:s'),
    'tz' => date_default_timezone_get(),
]));

Типичные ошибки

  • Строка вместо объекта в ORM. Иногда запишется, иногда получите странный фильтр. Держите DateTime до add/update.
  • date() от timestamp из JS. Браузер шлёт UTC ms, PHP форматирует в локали сервера — час «прыгает».
  • Разный timezone у FPM и CLI. Веб показывает одно, агент считает другое.
  • Сравнение строк d.m.Y. Лексикографически 01.10 < 30.09 — отчёты врут.
  • Мутация через add() без clone. Один объект уехал в фильтр и в лог уже «сдвинутым».
  • MySQL TIMESTAMP vs DATETIME. TIMESTAMP конвертируется в session time_zone; DATETIME — «как записали». Для Битрикс чаще DATETIME + дисциплина пояса в приложении.

Быстрая отладка

<?php
use Bitrix\Main\Type\DateTime;

$dt = new DateTime();
AddMessage2Log([
    'php_tz' => date_default_timezone_get(),
    'now' => $dt->format('Y-m-d H:i:s'),
    'ts' => $dt->getTimestamp(),
    'offset' => $dt->format('P'),
], 'local.datetime');

Сверьте три значения: вывод из веба, вывод из агента, SELECT NOW(); в MySQL под тем же пользователем, что у сайта.

Краткий чеклист

  1. В коде модулей — Type\DateTime/Type\Date, не сырые строки.
  2. Один канонический формат на границе UI (например d.m.Y H:i).
  3. Наружу — ISO-8601 с Z или offset.
  4. FPM и CLI с одной date.timezone.
  5. Перед арифметикой — clone, если объект ещё нужен «как был».
  6. В фильтрах ORM — объекты, не «почти правильные» строки.

Вывод

Bitrix\Main\Type\DateTime — не «обёртка ради обёртки», а способ не размножать strtotime и date() по проекту. Зафиксируйте пояс, парсьте явно, в ORM отдавайте объекты, во внешние API — UTC. Вместе с ServiceLocator удобно спрятать хелпер дат в сервис модуля и не тащить форматы в шаблоны. Дальше по контуру интеграций логично держать рядом HttpClient и единый контракт времени в payload.