Заказчик просит: «Пусть при оформлении заказа с самовывозом автоматически ставится комментарий для склада, а после смены статуса на „Оплачен“ уходит задача менеджеру». Задача житейская, но новичок на 1С-Битрикс сразу тянется править компонент оформления заказа или, того хуже, лезет в файлы модуля sale в папке bitrix. И то и другое — тупик: компонент отвечает за вывод, а не за бизнес-логику сохранения, а правка ядра исчезнет при первом обновлении платформы. Правильный инструмент здесь один — события.
Механизм событий (event handlers) — это официальная, поддерживаемая точка расширения Битрикса. Платформа в ключевых местах сама «зовёт» ваш код: перед добавлением пользователя, перед и после обновления товара, при сохранении заказа, при изменении корзины. Вы подписываете на нужный момент свою функцию — обработчик — и меняете поведение системы, ничего не ломая в её исходниках. Эта статья — практический разбор механизма: зачем он нужен, где регистрировать обработчики (init.php, local/php_interface), чем AddEventHandler отличается от D7-го addEventHandler, какие бывают события Битрикс у модулей и у ORM, как отменять действия, задавать приоритет и не уронить скорость. Материал — выжимка из наших проектов по разработке высоконагруженных проектов на 1С-Битрикс.
Коротко
- События — штатный способ менять поведение платформы, не правя ядро: ядро само вызывает ваш обработчик в нужный момент.
- Регистрируют обработчики в
init.php(/local/php_interface/init.php), а тело логики выносят в отдельный класс. - Два API: старый
AddEventHandlerи D7-йEventManager::addEventHandler— для модульных событий взаимозаменяемы. - Есть события модулей (
OnBeforeUserAdd,OnAfterIBlockElementAdd,OnSaleOrderSaved) и события ORM (onBeforeAdd,onAfterUpdate). - before-события умеют отменить действие (return false или исключение), after-события — только реагировать; тяжёлое из обработчика откладывают.
Зачем нужны события: менять поведение, не трогая ядро
Главная ценность механизма событий формулируется одной фразой: расширять систему, не трогая её код. Ядро Битрикса — это сотни тысяч строк в папке bitrix/modules, и они принадлежат платформе. Любая правка там держится ровно до ближайшего обновления, после которого затирается штатными файлами. Значит, «вписать» свою логику прямо в чужой код нельзя — нужна легальная точка, куда платформа сама пустит ваш код. Такой точкой и служат события.
Подумайте, что обычно требует бизнес от магазина: при регистрации клиента завести его в CRM, перед сохранением товара проверить уникальность артикула, после оплаты заказа списать бонусы и отправить чек, при смене статуса уведомить склад. Всё это — не про вывод (компоненты) и не про хранение (инфоблоки), а про реакцию на действия. Событие — это именно «крючок» на действие: система сообщает «сейчас произойдёт X» или «X только что произошёл», а вы решаете, что с этим делать.
Практических выигрышей у такого подхода три:
- Обновляемость. Ваш код живёт отдельно от ядра. Платформа обновляется, а обработчики продолжают работать, потому что контракт событий стабилен между версиями.
- Изоляция. Логику «при событии сделай X» видно в одном месте, а не размазано по компонентам и страницам. Проект читается и передаётся другому разработчику без «археологии».
- Обратимость. Отключить кастомную логику — значит убрать одну строку регистрации в
init.php. Никаких следов в чужих файлах, ничего не нужно «отменять».
Обратная сторона — соблазн засунуть в обработчик слишком много. События мощны, и легко превратить их в свалку бизнес-логики, которая тормозит каждое сохранение. Поэтому дальше мы одинаково внимательно разберём и как подписаться, и как не навредить: где регистрировать, что можно и нельзя, как не уронить производительность и как отлаживать, когда «событие не сработало».
Как устроен механизм событий в Битриксе
Под капотом всё устроено проще, чем кажется. У платформы есть внутренний реестр: «на такое-то событие такого-то модуля подписаны такие-то функции». Когда ядро доходит до момента, где предусмотрено событие, оно спрашивает реестр: «кто подписан?» — и по очереди вызывает всех найденных обработчиков, передавая им данные текущей операции. Обработчики выполняются синхронно, один за другим, прямо внутри действия.
Событие характеризуется двумя вещами: модулем-источником (например, main, iblock, sale, catalog) и именем события (например, OnBeforeUserAdd). Именно эта пара — «модуль + имя» — используется и при регистрации обработчика, и при вызове события. Ошибётесь в любом из двух — и подписка просто «повиснет в воздухе»: платформа не найдёт совпадения и ваш код не выполнится.
Исторически в Битриксе сложились два поколения событий, и это важно понимать, чтобы не путаться:
- «Старые» события модулей. Вызываются внутри ядра через
ExecuteModuleEventEx. Это классика:OnBeforeUserAdd,OnAfterIBlockElementUpdateи сотни других. Обработчик — обычная функция или статический метод. - «Новые» события D7. Публикуются через объект
Eventи методsend(), обработчики регистрируются черезEventManager. В этом же поколении живут события ORM (onBeforeAdd,onAfterUpdate) и часть событий модуляsaleв его D7-ветке.
Хорошая новость: для большинства «модульных» событий (тех, что через ExecuteModuleEventEx) оба API регистрации — старый и новый — работают одинаково, и обработчик, подписанный любым из них, будет вызван. А вот события ORM и часть D7-событий существуют только в новом механизме. Дальше разберём оба API и покажем, когда какой уместен.
Где регистрировать: init.php и local/php_interface
Первый практический вопрос — куда положить код регистрации, чтобы он гарантированно выполнялся до того, как события начнут вызываться. Ответ штатный: файл init.php. Платформа подключает его автоматически на каждом хите сразу после инициализации ядра, до обработки страницы и до любых действий с данными. Значит, регистрация обработчиков в init.php всегда происходит вовремя.
Расположений у init.php два, и выбор между ними — это выбор между «современно» и «по-старому»:
| Путь | Когда используется |
|---|---|
/local/php_interface/init.php | Современный проект: весь кастомный код в папке /local/, отделён от ядра |
/bitrix/php_interface/init.php | Классическое расположение; работает всегда, но мешает «своё» с системным |
Если в проекте есть папка /local/, регистрируйте обработчики в /local/php_interface/init.php — это чище: весь ваш код лежит отдельно от bitrix, проект легко переносить и обновлять. Если оба файла присутствуют, подключаются оба, но смешивать регистрации в двух местах — путь к путанице. Выберите одно место и держитесь его.
init.php выполняется на каждом хите — и на публичке, и в админке, и при API-запросах, и при обмене с 1С. Поэтому в самом файле не должно быть тяжёлой работы: только регистрация обработчиков и, максимум, подключение автозагрузки. Никаких запросов к базе «на всякий случай» — иначе вы замедлите вообще всё.
Типичный аккуратный init.php выглядит так — он только регистрирует, а логику держит в классе:
<?php
// /local/php_interface/init.php
use Bitrix\Main\Loader;
// Подключаем автозагрузку своих классов (если не через composer)
require_once __DIR__ . '/lib/OrderHandlers.php';
// Регистрация обработчиков — по одной строке на подписку
$eventManager = \Bitrix\Main\EventManager::getInstance();
$eventManager->addEventHandler(
'iblock',
'OnBeforeIBlockElementUpdate',
['\App\Handlers\CatalogHandlers', 'onBeforeElementUpdate']
);
$eventManager->addEventHandler(
'sale',
'OnSaleOrderSaved',
['\App\Handlers\OrderHandlers', 'onOrderSaved']
);
Заметьте: init.php не содержит бизнес-логики — только «кто на что подписан». Всё тело обработчиков живёт в классах CatalogHandlers и OrderHandlers. Это и есть здоровая структура, к которой мы ещё вернёмся в разделе про вынос тела обработчика.
AddEventHandler против EventManager D7
Теперь к двум API регистрации. Оба делают одно и то же — заносят вашу функцию в реестр событий, — но выглядят по-разному и появились в разное время. Разработчику важно уметь читать и писать оба: в живых проектах встречаются и старый, и новый стиль.
Старый способ — функция AddEventHandler. Процедурная, из ранних версий Битрикса. Сигнатура: модуль, имя события, обработчик, сортировка. Обработчиком может быть имя функции строкой или массив [класс, метод].
<?php
// Старый API — глобальная функция
AddEventHandler(
'main', // модуль-источник
'OnBeforeUserAdd', // имя события
'checkUserBeforeAdd', // имя функции-обработчика
100 // сортировка (по умолчанию 100)
);
function checkUserBeforeAdd(&$arFields)
{
// $arFields передан по ссылке — можно менять
if (empty($arFields['LOGIN'])) {
global $APPLICATION;
$APPLICATION->throwException('Логин обязателен');
return false; // отменяем добавление
}
return true;
}
Новый способ — EventManager::addEventHandler. Объектный интерфейс из D7. Получаете экземпляр менеджера событий и подписываете обработчик. Стиль тот же, но в духе пространств имён и классов.
<?php
use Bitrix\Main\EventManager;
$em = EventManager::getInstance();
$em->addEventHandler(
'main',
'OnBeforeUserAdd',
['\App\Handlers\UserHandlers', 'onBeforeUserAdd']
);
Для событий, которые ядро вызывает через ExecuteModuleEventEx (а это подавляющее большинство «модульных» событий), оба варианта равнозначны — обработчик отработает независимо от того, каким API вы подписались. Разница проявляется на «настоящих» D7-событиях (send/Event), включая события ORM: их регистрируют только через addEventHandler, и обработчик получает на вход объект Event, а не набор параметров по ссылке. Сведём в таблицу.
| Признак | AddEventHandler (старый) | EventManager::addEventHandler (D7) |
|---|---|---|
| Стиль | Глобальная функция | Объект + метод |
| Что на входе обработчика | Параметры по ссылке (&$arFields) | Параметры по ссылке или объект Event |
| Модульные события (ExecuteModuleEventEx) | Да | Да |
| Настоящие D7-события (send) | Нет | Да |
| События ORM (onBeforeAdd…) | Нет | Да |
| Отмена действия | return false / throwException | Через EventResult / исключение |
Практический совет для новых проектов: используйте D7-й EventManager::addEventHandler — он единообразно подписывает и старые, и новые события, лучше ложится на классовую архитектуру и не заставляет держать в голове две разные функции. Старый AddEventHandler вы всё равно будете встречать в унаследованном коде, поэтому читать его надо уметь, но писать новое лучше в D7-стиле. Про то, как события ORM вписаны в общую модель данных D7, мы подробно рассказываем в статье про D7 ORM в 1С-Битрикс.
Анатомия обработчика: что приходит на вход
Обработчик — это просто функция или метод, который платформа вызовет в момент события. Ключевой вопрос: что она получает на вход и что должна вернуть? Ответ зависит от поколения события, и здесь чаще всего ошибаются новички.
Старые модульные события передают параметры, как правило, по ссылке. Например, OnBeforeIBlockElementUpdate отдаёт массив полей элемента &$arFields — вы можете прочитать его и, что важно, изменить прямо в обработчике: правка полей повлияет на то, что реально сохранится. Возврат false из before-обработчика отменяет действие.
<?php
// Обработчик старого события: параметры по ссылке
public static function onBeforeElementUpdate(&$arFields)
{
// Нормализуем артикул перед сохранением
if (isset($arFields['PROPERTY_VALUES']['ARTICLE'])) {
// ... привести к верхнему регистру, убрать пробелы ...
}
// Запрет пустого названия — отменяем обновление
if (isset($arFields['NAME']) && trim($arFields['NAME']) === '') {
global $APPLICATION;
$APPLICATION->throwException('Название товара не может быть пустым');
return false;
}
return true;
}
Новые D7-события передают в обработчик один аргумент — объект \Bitrix\Main\Event. Данные достаются из него методом getParameter(), а «ответ» обработчика (в том числе отмену) возвращают через EventResult. Это единообразнее, но требует другого кода:
<?php
use Bitrix\Main\Event;
use Bitrix\Main\EventResult;
public static function onOrderSavedD7(Event $event)
{
/** @var \Bitrix\Sale\Order $order */
$order = $event->getParameter('ENTITY');
$orderId = $order->getId();
// ... ваша логика ...
return new EventResult(EventResult::SUCCESS);
}
Отсюда правило, которое спасает часы отладки: сигнатура обработчика должна соответствовать поколению события. Подпишете D7-обработчик с параметром Event $event на старое событие, отдающее &$arFields, — и получите либо ошибку, либо тихо не работающую логику. Всегда сверяйтесь с документацией конкретного события: что оно передаёт и как отменяется.
Куда выносить тело обработчика
Соблазн написать логику прямо в init.php велик, но это дорога к файлу на две тысячи строк, в котором невозможно разобраться. Здоровый подход — разделять регистрацию и реализацию: в init.php только строки addEventHandler, а тела обработчиков — в классах, сгруппированных по смыслу.
Практическая схема, которая хорошо работает на проектах:
- Заведите папку для своих классов. Например,
/local/php_interface/lib/Handlers/или отдельный модуль в/local/modules/. - Сгруппируйте обработчики по домену.
OrderHandlers— всё про заказы,CatalogHandlers— про товары,UserHandlers— про пользователей. Один класс — одна предметная область. - Делайте методы статическими. Обработчики обычно не хранят состояние, а статический метод удобно указать в регистрации массивом
[класс, метод]. - Подключите автозагрузку. Через composer PSR-4 или простой
Loader::registerAutoLoadClassesвinit.php— чтобы классы находились без ручныхrequire. - В
init.phpоставьте только подписки. Прочитав его, любой разработчик за минуту видит полную карту «что на что подписано».
<?php
// /local/php_interface/lib/Handlers/OrderHandlers.php
namespace App\Handlers;
class OrderHandlers
{
public static function onOrderSaved(\Bitrix\Main\Event $event)
{
// Вся логика реакции на сохранение заказа — здесь
}
public static function onStatusChanged(\Bitrix\Main\Event $event)
{
// Реакция на смену статуса — отдельным методом
}
}
Такая структура окупается на первом же рефакторинге: обработчики тестируемы, находятся по имени класса, а init.php остаётся «оглавлением» кастомной логики проекта. Это тот же принцип разделения ответственности, что и в шаблонах и компонентах Битрикса, где вёрстку держат отдельно от данных.
До и после: before- и after-события
Почти у каждого действия в Битриксе есть два события-близнеца: before (перед действием) и after (после). Разница между ними принципиальна и определяет, что вы вообще можете сделать в обработчике. Спутать их — классическая ошибка, из-за которой «код вроде правильный, а не работает».
Before-событие вызывается до того, как действие совершилось. В этот момент данные ещё не записаны, поэтому обработчик может: проверить их, изменить (правка полей повлияет на итог) и — главное — отменить действие. Именно before используют для валидации, нормализации и запретов. Пример: OnBeforeIBlockElementUpdate — можно поправить артикул или запретить сохранение с пустым названием.
After-событие вызывается после того, как действие уже произошло и данные записаны. Отменить его нельзя — поезд ушёл. Зато на входе доступен реальный результат, в том числе новый ID у только что созданной сущности. After используют для реакции: отправить уведомление, поставить задачу, синхронизировать с внешней системой. Пример: OnAfterIBlockElementAdd — товар уже создан, знаем его ID, можно завести его в стороннем сервисе.
| Аспект | before-событие | after-событие |
|---|---|---|
| Момент вызова | До записи данных | После записи данных |
| Можно менять данные | Да (правка повлияет на итог) | Смысла нет — уже сохранено |
| Можно отменить действие | Да | Нет |
| Доступен новый ID | Нет (ещё не создан) | Да |
| Типичное применение | Валидация, нормализация, запрет | Уведомления, интеграции, задачи |
Практический разбор: если вам нужно «проверить и не пустить» — берите before. Если «отреагировать на свершившийся факт и узнать ID» — берите after. И держите в голове опасную комбинацию: тяжёлую интеграцию нельзя вешать на before (замедлит и может сорвать само действие), а валидацию — на after (данные уже записаны, отменять поздно).
События модулей: главные точки расширения
Событий в Битриксе — сотни, но в реальной работе магазина используется вполне обозримый набор. Полезно держать в голове «карту» самых частых событий по модулям: она подсказывает, куда подписаться под конкретную задачу. Собрали ходовые в таблицу.
| Модуль | Событие | Когда вызывается |
|---|---|---|
main | OnBeforeUserAdd / OnAfterUserAdd | До/после регистрации пользователя |
main | OnBeforeUserUpdate / OnAfterUserUpdate | До/после изменения профиля |
main | OnAfterUserAuthorize | После успешной авторизации |
iblock | OnBeforeIBlockElementAdd / OnAfterIBlockElementAdd | До/после создания элемента (товара) |
iblock | OnBeforeIBlockElementUpdate / OnAfterIBlockElementUpdate | До/после обновления элемента |
iblock | OnAfterIBlockElementDelete | После удаления элемента |
sale | OnSaleOrderBeforeSaved / OnSaleOrderSaved | До/после сохранения заказа |
sale | OnSaleStatusOrderChange | При смене статуса заказа |
sale | OnSaleOrderPaid | При переводе заказа в «оплачен» |
catalog | OnPriceUpdate / OnPriceAdd | При изменении/добавлении цены |
main | OnEndBufferContent | Перед отдачей готового HTML (постобработка) |
Это не полный список, а «рабочий минимум», который закрывает большинство задач магазина. Точные имена и параметры конкретного события всегда сверяйте с официальной документацией — там указано, что событие передаёт и как отменяется. Отдельно обратите внимание: у модуля sale есть и старые события, и D7-события (в его новой ветке), поэтому перед подпиской важно понять, какое поколение вы используете.
События инфоблоков и каталога
Для магазина инфоблоки — это товары, а значит события инфоблоков используются постоянно. Самое ходовое — OnBeforeIBlockElementUpdate и его пара OnAfterIBlockElementUpdate. Первое даёт вмешаться в сохранение товара (проверить, нормализовать, запретить), второе — отреагировать на уже сохранённый (пересчитать, синхронизировать).
Разберём типичную задачу: перед сохранением товара нужно гарантировать, что артикул записан в едином формате (верхний регистр, без лишних пробелов), а название непустое. Это чистый случай для before-события:
<?php
namespace App\Handlers;
class CatalogHandlers
{
public static function onBeforeElementUpdate(&$arFields)
{
// Нормализуем название
if (isset($arFields['NAME'])) {
$arFields['NAME'] = trim($arFields['NAME']);
if ($arFields['NAME'] === '') {
global $APPLICATION;
$APPLICATION->throwException('Название товара не может быть пустым');
return false; // отменяем обновление
}
}
return true;
}
}
А теперь after-сценарий: товар обновили, и нужно пометить его раздел как «требует пересчёта остатков» или отправить сигнал во внешнюю систему. Тут доступен реальный ID, поэтому берём OnAfterIBlockElementUpdate:
<?php
public static function onAfterElementUpdate(&$arFields)
{
$elementId = (int)($arFields['ID'] ?? 0);
if ($elementId <= 0) {
return;
}
// Ставим задачу на пересчёт/выгрузку, а не делаем её прямо здесь
\App\Queue\Tasks::push('product_changed', ['id' => $elementId]);
}
Важная тонкость про OnAfterIBlockElementUpdate: он вызывается при каждом обновлении элемента, включая массовые операции и обмен с 1С, где за минуту могут пройти тысячи товаров. Поэтому в after-обработчике товара категорически нельзя делать тяжёлую синхронную работу — иначе импорт «встанет». В примере выше мы лишь ставим задачу в очередь, а сам пересчёт выполняем отдельно. К производительности обработчиков вернёмся в отдельном разделе — это одно из самых частых мест, где проекты «просаживаются».
События цен (catalog: OnPriceUpdate, OnPriceAdd) полезны, когда логика завязана именно на цену, а не на карточку товара, — например, при изменении базовой цены пересчитать зависимые типы цен или сбросить связанный кэш. А как события каталога связаны с обменом товаров и цен с учётной системой, подробно разбираем в материале про интеграцию магазина с 1С.
События заказа и корзины (модуль sale)
Заказ — сердце магазина, и именно вокруг него крутится больше всего кастомной логики. Модуль sale даёт богатый набор событий: на сохранение заказа, смену статуса, оплату, изменение корзины. С их помощью реализуют автостатусы, уведомления, списание бонусов, интеграцию с CRM и складом.
Ключевые события заказа:
OnSaleOrderBeforeSaved— перед сохранением заказа. Можно дописать свойства, проставить служебные поля, при необходимости отменить сохранение.OnSaleOrderSaved— после сохранения. Заказ уже есть в базе, знаем его ID и итоговую сумму — самое место для реакций: уведомить, поставить задачу, отправить в CRM.OnSaleStatusOrderChange— при смене статуса. Основа для автостатусов и уведомлений «ваш заказ передан в доставку».OnSaleOrderPaid— когда заказ отмечен оплаченным. Триггер для чека, отгрузки, начисления бонусов.
Пример D7-обработчика на сохранение заказа, который дописывает служебный комментарий для самовывоза:
<?php
use Bitrix\Main\Event;
class OrderHandlers
{
public static function onOrderSaved(Event $event)
{
/** @var \Bitrix\Sale\Order $order */
$order = $event->getParameter('ENTITY');
$isNew = $event->getParameter('IS_NEW');
if (!$isNew) {
return; // реагируем только на первое сохранение
}
$orderId = $order->getId();
// ... прочитать способ доставки, при самовывозе пометить для склада ...
\App\Queue\Tasks::push('order_created', ['id' => $orderId]);
}
}
Обратите внимание на флаг IS_NEW: событие сохранения срабатывает и при создании, и при любом изменении заказа, поэтому реагировать «как на новый заказ» нужно только когда он действительно новый. Это частая ловушка: без проверки уведомление о новом заказе улетает при каждом редактировании в админке. Событий у заказа много, и почти каждое требует такой аккуратной фильтрации «а точно ли это тот случай, на который я реагирую».
sale живёт на D7-объектах (\Bitrix\Sale\Order, \Bitrix\Sale\Basket). Поэтому и события у него D7-стиля: обработчик получает объект Event, а сущность достаёт через getParameter('ENTITY'). Старые процедурные события заказа тоже существуют для совместимости, но новый код логичнее писать на D7-событиях.
События ORM: onBeforeAdd и onAfterUpdate
Отдельное большое семейство — события ORM. Они относятся не к модулям, а к сущностям DataManager (highload-блоки, свои таблицы, D7-сущности). Как только вы работаете с данными через ORM, у их таблиц автоматически появляются события жизненного цикла: onBeforeAdd, onAfterAdd, onBeforeUpdate, onAfterUpdate, onBeforeDelete, onAfterDelete.
Есть два способа их использовать. Первый — объявить обработчик прямо в классе сущности (метод-обработчик события), что удобно, когда логика неразрывна с самой таблицей. Второй — подписаться извне через EventManager, указав полное имя класса сущности как «модуль». Пример обработчика в самом классе:
<?php
use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Event;
use Bitrix\Main\ORM\EventResult;
class StatusLogTable extends DataManager
{
public static function onBeforeAdd(Event $event)
{
$result = new EventResult();
$data = $event->getParameter('fields');
// Проставим дату, если её не передали
if (empty($data['CREATED_AT'])) {
$result->modifyFields(['CREATED_AT' => new \Bitrix\Main\Type\DateTime()]);
}
return $result; // modifyFields изменит сохраняемые поля
}
public static function onAfterUpdate(Event $event)
{
// Реакция на уже применённое обновление записи
}
}
Ключевые отличия событий ORM от старых модульных: обработчик всегда получает объект Event (ORM-версию), данные достаёт через getParameter(), а изменить сохраняемые поля можно только «правильно» — через EventResult::modifyFields(), а не прямой правкой массива по ссылке. Отмену действия тоже оформляют через EventResult с ошибкой. Это строже и предсказуемее старого API. Если вы строите бизнес-логику на собственных D7-сущностях, события ORM — основной инструмент; их устройство мы детально разбираем в статье про D7 ORM в 1С-Битрикс.
Отмена действия и исключения
Одна из самых востребованных возможностей — запретить действие из обработчика. «Не давать создать заказ без телефона», «не сохранять товар с дублирующимся артикулом», «не регистрировать пользователя с запрещённого домена» — всё это отмены в before-событиях. Но механика отмены различается по поколениям событий, и здесь легко ошибиться.
- Старые модульные before-события. Обработчик возвращает
false— и действие прерывается. Чтобы пользователь увидел причину, текст ошибки задают через$APPLICATION->throwException('...')перед возвратомfalse. - События ORM. Отмену оформляют через
EventResultс добавленной ошибкой (addError) — ядро увидит ошибку в результате и не выполнит операцию. - D7-события модулей (send). Как правило, бросают исключение или возвращают результат с ошибкой; ядро перехватывает его и отменяет действие, а сообщение попадает в результат операции.
<?php
// Отмена в старом before-событии
public static function onBeforeUserAdd(&$arFields)
{
$email = $arFields['EMAIL'] ?? '';
if (str_ends_with($email, '@example-blocked.tld')) {
global $APPLICATION;
$APPLICATION->throwException('Регистрация с этого домена запрещена');
// Пишем причину в лог — иначе отладка вслепую
\App\Log::warning('user.add.blocked', ['email' => $email]);
return false;
}
return true;
}
Два правила, которые уберегут от боли. Первое: отменять можно только в before. After-событие вызывается после того, как всё уже записано; вернуть там false бесполезно — данные не откатятся. Если нужна отмена, ищите before-пару события. Второе: всегда логируйте причину отмены. «Заказ не сохраняется, а почему — непонятно» — типичная жалоба, за которой стоит молчаливый обработчик, отменяющий действие без единой записи в лог. Сообщение пользователю плюс запись в лог — обязательный минимум.
Порядок и приоритет обработчиков
На одно событие можно подписать сколько угодно обработчиков — из вашего кода, из ядра, из сторонних модулей. Все они выполняются последовательно, и порядок задаётся сортировкой. У AddEventHandler это четвёртый аргумент (по умолчанию 100), у D7-го addEventHandler — параметр приоритета с той же логикой: чем меньше число, тем раньше выполнится обработчик.
<?php
$em = \Bitrix\Main\EventManager::getInstance();
// Выполнится раньше (сортировка 50)
$em->addEventHandler('iblock', 'OnBeforeIBlockElementUpdate',
['\App\Handlers\CatalogHandlers', 'normalize'], false, 50);
// Выполнится позже (сортировка 200)
$em->addEventHandler('iblock', 'OnBeforeIBlockElementUpdate',
['\App\Handlers\CatalogHandlers', 'validate'], false, 200);
Для большинства задач порядок неважен: обработчики независимы, и в каком очерёдности они отработают — всё равно. Но есть случаи, где сортировка критична:
- Один обработчик готовит данные для другого. Например, сначала нормализуем поля, потом валидируем — нормализатор должен идти раньше.
- Раннее прерывание. Если before-обработчик отменяет действие, в ряде сценариев последующие уже не выполнятся. Значит, «дешёвые» проверки, часто отменяющие действие, стоит ставить раньше тяжёлых.
- Взаимодействие с чужими модулями. Сторонний модуль может подписаться на то же событие. Явная сортировка помогает встроиться в нужное место цепочки, а не полагаться на случай.
Главный совет: не полагайтесь на «естественный» порядок регистрации. Он кажется предсказуемым, пока в проект не добавят ещё один модуль или не поменяют порядок строк в init.php. Если очерёдность важна — задавайте сортировку явными числами и оставляйте между ними зазор (50, 100, 150), чтобы позже было куда вставить промежуточный обработчик.
Пример: модификация заказа перед сохранением
Соберём первый полноценный кейс. Задача: перед сохранением заказа нужно автоматически проставить служебное свойство — например, канал оформления («сайт», «мобильное приложение») и пометку о необходимости звонка, если сумма выше порога. Это классический before-сценарий: данные ещё не записаны, и мы влияем на итог.
Регистрация в init.php:
<?php
$em = \Bitrix\Main\EventManager::getInstance();
$em->addEventHandler(
'sale',
'OnSaleOrderBeforeSaved',
['\App\Handlers\OrderHandlers', 'onBeforeOrderSaved']
);
Тело обработчика в классе:
<?php
namespace App\Handlers;
use Bitrix\Main\Event;
class OrderHandlers
{
public static function onBeforeOrderSaved(Event $event)
{
/** @var \Bitrix\Sale\Order $order */
$order = $event->getParameter('ENTITY');
// Помечаем крупные заказы для обязательного звонка
$price = (float)$order->getPrice();
if ($price >= 50000) {
$propertyCollection = $order->getPropertyCollection();
$callProp = $propertyCollection->getItemByOrderPropertyCode('NEED_CALL');
if ($callProp) {
$callProp->setValue('Y');
}
}
// Возврат управления — заказ сохранится с нашими правками
}
}
Разберём важные детали. Мы работаем с D7-объектом Order, а не с массивом полей: читаем сумму методом getPrice(), свойства — через коллекцию свойств. Мы не вызываем save() внутри обработчика — заказ и так сохраняется ядром, а наша задача лишь подготовить его состояние. Вызов save() здесь спровоцировал бы повторное сохранение и, возможно, рекурсию событий — типичная ошибка. В before-обработчике меняем состояние объекта и отдаём управление, а запись сделает платформа.
Пример: автостатусы заказа
Второй кейс — автоматические статусы. Задача: когда заказ переходит в статус «Оплачен», нужно автоматически перевести его в «Готов к сборке» и запустить резервирование. Отлично ложится на событие смены статуса OnSaleStatusOrderChange (или, в D7-ветке, на событие оплаты).
<?php
// Регистрация (старое событие смены статуса)
AddEventHandler('sale', 'OnSaleStatusOrderChange',
['\App\Handlers\OrderHandlers', 'onStatusChange']);
// Обработчик
public static function onStatusChange($orderId, $statusId)
{
// Реагируем только на переход в «Оплачен» (условно статус 'P')
if ($statusId !== 'P') {
return;
}
// Ставим задачу: перевести в сборку и зарезервировать
\App\Queue\Tasks::push('order_paid', ['id' => (int)$orderId]);
}
Здесь спрятаны две важные мысли. Первая: не меняйте статус заказа прямо внутри обработчика смены статуса «в лоб» — это верный путь к рекурсии, когда ваше изменение статуса снова поднимает то же событие, которое снова меняет статус, и так по кругу. Безопаснее зафиксировать намерение (поставить задачу) и выполнить смену отдельным управляемым шагом, а если делаете это синхронно — обязательно предусмотрите защиту от повторного входа (флаг «уже обрабатывается»).
Вторая мысль: автостатусы легко превращаются в запутанный «конечный автомат», где статусы переключают друг друга по цепочке. Держите правила переходов в одном месте и прозрачными — иначе через полгода никто не сможет ответить, почему заказ сам перескочил из одного статуса в другой. Событие — это триггер, а логику переходов лучше вынести в отдельный сервис-класс, который легко прочитать и протестировать.
Пример: уведомления по событию
Третий кейс — уведомления. Задача: после создания нового заказа отправить менеджеру письмо, а клиенту — сообщение в мессенджер. Это after-сценарий (OnSaleOrderSaved с проверкой IS_NEW), потому что нам нужен готовый заказ с ID и суммой, и отменять ничего не надо.
<?php
public static function onOrderSaved(\Bitrix\Main\Event $event)
{
/** @var \Bitrix\Sale\Order $order */
$order = $event->getParameter('ENTITY');
if (!$event->getParameter('IS_NEW')) {
return; // только новые заказы
}
// НЕ отправляем письмо прямо здесь — ставим в очередь
\App\Queue\Tasks::push('notify_new_order', [
'id' => $order->getId(),
'sum' => $order->getPrice(),
]);
}
Ключевая рекомендация выделена намеренно: не отправляйте уведомления синхронно внутри обработчика. Отправка письма или запрос к API мессенджера — это обращение к внешней сети, которое может длиться секунды или упасть по таймауту. Если делать это прямо в обработчике сохранения заказа, то, во-первых, оформление заказа для клиента затянется на время отправки, а во-вторых, падение почтового сервера может сорвать сам заказ (или бросить ошибку в неподходящий момент).
Поэтому обработчик лишь ставит задачу «уведомить по заказу №N», а фактическую отправку выполняет отдельный процесс — агент, крон или очередь. Клиент моментально получает подтверждение оформления, а письма и сообщения уходят следом, не блокируя оформление. Тему отложенных задач разберём отдельно — это универсальный паттерн для любой «тяжёлой реакции» в обработчиках.
Пример: пересчёт и интеграция по событию
Четвёртый кейс — интеграция. Задача: при изменении товара (цена, остаток, свойства) отправить его в внешнюю систему — CRM, маркетплейс, поисковый индекс. Триггер — OnAfterIBlockElementUpdate и OnAfterIBlockElementAdd. И именно здесь производительность становится критичной.
Наивная реализация «в лоб» — прямо в обработчике дёрнуть API маркетплейса — почти гарантированно сломает обмен с 1С. Во время обмена товары обновляются пачками по тысячам, каждое обновление поднимает событие, и если на каждое ходить во внешний API по сети, обмен растянется на часы или свалится по таймауту. Правильная схема — событие фиксирует изменение, а выгрузка идёт отдельно и пачками:
<?php
public static function onAfterElementUpdate(&$arFields)
{
$id = (int)($arFields['ID'] ?? 0);
if ($id <= 0) return;
// Только пометка «товар изменён» — быстрая запись в свою таблицу
\App\Sync\ChangeLog::mark($id, 'update');
}
Обработчик делает единственную дешёвую операцию — помечает товар в журнале изменений (своя таблица или очередь). А отдельный агент раз в N минут забирает накопленные метки, группирует их и выгружает пачкой в один-два запроса к внешней системе. Так событие остаётся мгновенным, обмен с 1С не тормозит, а внешняя система получает изменения с небольшой предсказуемой задержкой — что почти всегда приемлемо.
Эта развязка «событие → журнал → фоновая выгрузка» — фундамент любой надёжной интеграции на событиях. Она делает систему устойчивой к сбоям (упал внешний API — метки просто накопятся и выгрузятся позже) и к нагрузке (тысяча изменений превращается в одну пачку, а не тысячу запросов). Как выстроить такую двустороннюю синхронизацию с учётной системой, подробно разбираем в материале про интеграцию магазина с 1С.
Отложенные задачи из обработчика
Мы несколько раз повторили: тяжёлое из обработчика надо откладывать. Разберём, как именно это делают в Битриксе. Есть три основных механизма, и выбор зависит от требований к задержке и надёжности.
| Механизм | Как работает | Когда уместен |
|---|---|---|
| Агент | Штатные агенты Битрикса: функция выполняется по расписанию (на хите или по крону) | Периодические пачечные задачи: выгрузки, пересчёты |
| Своя очередь | Обработчик пишет задачу в таблицу, отдельный процесс её разбирает | Когда нужен контроль порядка, повторов и статусов |
| Внешняя очередь | Постановка в брокер сообщений, воркер обрабатывает асинхронно | Высокая нагрузка, изоляция, масштабирование воркеров |
Для большинства магазинов достаточно первых двух. Простейший вариант — агент: обработчик лишь ставит отметку, а агент, запускаемый по крону, раз в несколько минут разбирает накопленное. Важно, чтобы агенты запускались именно кроном, а не «на хите пользователя», — иначе тяжёлый агент отработает во время чьего-то запроса и замедлит страницу ровно тому посетителю, которому не повезло его запустить.
<?php
// Агент разбирает накопленные изменения пачкой
class SyncAgent
{
public static function run()
{
$batch = \App\Sync\ChangeLog::takeBatch(200); // до 200 за раз
if ($batch) {
\App\Sync\Exporter::export($batch); // один запрос на пачку
\App\Sync\ChangeLog::markDone($batch);
}
return '\App\SyncAgent::run();'; // самоперезапуск агента
}
}
Смысл паттерна в развязке во времени: событие происходит мгновенно и лишь фиксирует «надо сделать», а собственно работа выполняется позже, отдельно, пачками и с контролем повторов. Это делает и само действие (сохранение заказа, обновление товара) быстрым, и тяжёлую работу — управляемой. Если у вас уже есть очередь или брокер, обработчик просто кладёт задачу туда; принцип тот же.
Производительность: тяжёлый обработчик тормозит
Обработчики — синхронный код внутри действия, и это главный источник неожиданных «тормозов». Каждая миллисекунда, потраченная в обработчике, добавляется ко времени того действия, к которому он подписан. А поскольку одни и те же события срабатывают и на единичных операциях, и на массовых, цена ошибки нелинейна.
Наглядный пример. Вы повесили на OnAfterIBlockElementUpdate обработчик, который на каждое обновление товара делает запрос к внешнему API длительностью 300 мс. В админке правка одного товара стала медленнее на треть секунды — терпимо. Но пришёл обмен с 1С и обновил 5000 товаров: 5000 × 300 мс = 25 минут только на ваши запросы, обмен упёрся в таймаут и упал. Один невинный обработчик обрушил интеграцию.
Правила, которые держат обработчики быстрыми:
- Никакой сети синхронно. Запросы к внешним API, отправка писем, обращения к сторонним сервисам — только через отложенные задачи, никогда напрямую в обработчике.
- Минимум запросов к базе. Не делайте в обработчике тяжёлых выборок и тем более запросов в цикле. Нужны данные — берите их одним запросом или из уже переданных параметров.
- Ранний выход. В начале обработчика проверьте условие и, если реагировать не нужно, выйдите сразу. Обработчик, который на 99% вызовов ничего не делает, не должен тратить время на эти 99%.
- Помните про массовые операции. Любое событие данных срабатывает и при импорте. Прикидывайте стоимость обработчика, умноженную на тысячи вызовов, а не на один.
- Профилируйте. Если действие вдруг стало медленным, проверьте, не появился ли новый обработчик. Штатный профилировщик и логи времени помогают локализовать виновника.
Производительность обработчиков тесно связана с общей скоростью магазина и кэшированием: медленное сохранение данных бьёт по админке и обмену, а не по публичке, но именно оно чаще всего остаётся незамеченным до аварии. Держите обработчики лёгкими — это дешевле, чем потом разбирать, почему обмен с 1С идёт полночи.
Отладка: почему событие не сработало
«Написал обработчик, а он не срабатывает» — самая частая жалоба новичка. Причин у молчащего обработчика немного, и они хорошо диагностируются по чек-листу. Пройдём по порядку от самого частого к более редкому.
- Не тот init.php. Проверьте, что регистрация в том файле, который реально подключается. Если проект работает через
/local/, а вы дописали в/bitrix/php_interface/init.php, — код может не выполняться. Поставьте в начало нужногоinit.phpвременную запись в лог и убедитесь, что файл подключается. - Опечатка в модуле или имени события.
sale— неSale,OnAfterIBlockElementAdd— неOnAfterIblockElementAdd. Регистр и точное имя важны. Сверьтесь с документацией посимвольно. - Не то поколение события. Подписались на D7-событие старым способом или наоборот. Проверьте, как событие вызывается в ядре, и подпишитесь соответствующим API.
- Обработчик падает раньше логики. Ошибка в классе, неверный namespace, класс не найден автозагрузкой. Включите отображение и логирование ошибок, посмотрите лог PHP.
- Действие идёт в обход события. Прямой SQL, низкоуровневый импорт или метод, который намеренно не поднимает события. Тогда обработчик и не должен срабатывать — ищите другую точку.
Универсальный приём отладки — лог в самой первой строке обработчика. Прежде чем разбираться, что обработчик делает не так, убедитесь, что управление вообще в него входит:
<?php
public static function onOrderSaved(\Bitrix\Main\Event $event)
{
// Первым делом фиксируем сам факт входа
\App\Log::debug('order.saved.handler.enter', [
'orderId' => $event->getParameter('ENTITY')->getId(),
]);
// ... остальная логика ...
}
Если записи в логе нет — проблема в регистрации (пункты 1–3): обработчик не вызывается вовсе. Если запись есть, а результата нет — проблема внутри логики (пункт 4 или бизнес-условия). Это простое разделение «входит / не входит» экономит часы: вы сразу знаете, где искать. Для событий-отмен добавляйте в лог и причину отказа — иначе «действие не выполняется, а почему — молчок» превращается в мучительный поиск вслепую.
\App\Log в примерах) с записью в файл или в журнал событий Битрикса. Единая точка логирования обработчиков бесценна: включили подробный уровень — видите весь поток событий; выключили — тишина в продакшене. Это дешевле, чем каждый раз расставлять var_dump по коду.
Регистрация обработчиков из своего модуля
Когда кастомной логики становится много, регистрировать всё в init.php перестаёт быть удобным. Зрелый путь — собственный модуль в /local/modules/, который сам регистрирует свои обработчики при установке. Это делает логику самодостаточной: подключили модуль — подписки появились, отключили — исчезли, без ручной правки init.php.
У модуля есть класс установки с методами InstallEvents() / UnInstallEvents(), где через registerEventHandler() / unRegisterEventHandler() подписки сохраняются в базу. В отличие от регистрации в init.php (она действует только на время хита), такие подписки становятся постоянными и переживают перезагрузку.
<?php
// В классе установки модуля
public function InstallEvents()
{
$em = \Bitrix\Main\EventManager::getInstance();
$em->registerEventHandler(
'sale', 'OnSaleOrderSaved',
$this->MODULE_ID, // ваш модуль
'\App\Handlers\OrderHandlers', 'onOrderSaved'
);
}
public function UnInstallEvents()
{
$em = \Bitrix\Main\EventManager::getInstance();
$em->unRegisterEventHandler(
'sale', 'OnSaleOrderSaved',
$this->MODULE_ID,
'\App\Handlers\OrderHandlers', 'onOrderSaved'
);
}
Разница между addEventHandler и registerEventHandler принципиальна. addEventHandler (и старый AddEventHandler) действует только в рамках текущего хита — поэтому его и зовут в init.php, который выполняется каждый раз. registerEventHandler сохраняет подписку в базу навсегда, поэтому его вызывают один раз при установке модуля. Не смешивайте их: подписка из модуля не должна дублироваться в init.php, иначе обработчик выполнится дважды.
Когда что выбирать? Для небольших проектов и точечной логики достаточно init.php с addEventHandler — просто и наглядно. Для крупного проекта с переиспользуемой логикой, которую хочется включать/выключать как единое целое и переносить между сайтами, оправдан свой модуль с постоянной регистрацией. Оба подхода легитимны; выбор — вопрос масштаба и повторного использования.
Частые ошибки
Ошибки с обработчиками повторяются из проекта в проект. Зная их заранее, вы сэкономите дни отладки и убережёте магазин от коварных багов, которые проявляются только под нагрузкой.
- Тяжёлая работа синхронно в обработчике. Запрос к внешнему API или отправка письма прямо в
OnAfter…тормозит действие и рушит массовые операции. Откладывайте через агент/очередь. - Отмена в after-событии. Возврат
falseв after ничего не отменяет — данные уже записаны. Для запретов используйте before-пару события. - Молчаливая отмена без лога. Обработчик тихо возвращает
false, и «заказ не сохраняется по непонятной причине». Всегда логируйте причину и показывайте сообщение пользователю. - Реакция без проверки
IS_NEW. Уведомление о новом заказе улетает при каждом редактировании. Фильтруйте случай, на который реально реагируете. - Рекурсия событий. Обработчик
onUpdate, который сам вызываетupdateтой же сущности, зацикливается. Меняйте состояние без повторной записи или ставьте флаг «уже обрабатывается». - Логика прямо в
init.php. Файл распухает и становится нечитаемым. Вinit.php— только регистрация, тело — в классах. - Опечатка в модуле/имени события. Обработчик «повисает» и не вызывается. Сверяйте имя и регистр с документацией, проверяйте вход логом.
- Неверное поколение API. D7-сигнатура на старом событии или наоборот. Обработчик получает не то, что ждёт. Сверяйте, как событие вызывается ядром.
- Дубль регистрации. Одна подписка и в модуле (
registerEventHandler), и вinit.php(addEventHandler) — обработчик срабатывает дважды. Выберите одно место. - Неидемпотентная реакция. Повторное срабатывание события начисляет бонусы или шлёт письмо дважды. Помечайте выполненное и проверяйте флаг перед действием.
Чек-лист по обработчикам
Короткий список, по которому удобно свериться при разработке и перед релизом магазина на Битриксе:
- Регистрация обработчиков — в правильном
init.php(обычно/local/php_interface/init.php), в одном месте. - В
init.phpтолько подписки; тела обработчиков — в отдельных классах, сгруппированных по домену. - Для нового кода используется D7-й
EventManager::addEventHandler; сигнатура обработчика соответствует поколению события. - Запреты и валидация висят на before-событиях; реакции и интеграции — на after.
- Отмена действия сопровождается сообщением пользователю и записью в лог.
- Реакции проверяют условие (например,
IS_NEW) и делают ранний выход, если реагировать не нужно. - Никакой синхронной сети в обработчике: письма, внешние API и пересчёты — через отложенные задачи (агент/очередь).
- Учтено массовое срабатывание при импорте и обмене с 1С; стоимость обработчика прикинута на тысячи вызовов.
- Нет рекурсии: обработчик не вызывает повторную запись той же сущности без защиты.
- Реакции идемпотентны: повтор события не начисляет и не отправляет дважды.
- В начале обработчика есть точка логирования «вход» для быстрой диагностики.
- Если логика оформлена своим модулем — используется постоянная
registerEventHandler, без дубля вinit.php.
Вывод
Механизм событий — это то, что превращает 1С-Битрикс из «коробки, которую страшно трогать» в расширяемую платформу. Вместо хирургии по чужому коду вы подписываете свои обработчики на нужные моменты — до и после регистрации пользователя, обновления товара, сохранения заказа, смены статуса — и меняете поведение системы, не тронув ни строчки ядра. Регистрация живёт в init.php, тело — в аккуратных классах, а два API (AddEventHandler и D7-й addEventHandler) покрывают и старые модульные события, и события ORM.
Практический стержень зрелой работы с событиями — дисциплина. Запреты — только в before и с логом; реакции и интеграции — в after и через отложенные задачи, чтобы не тормозить действие и не рушить обмен с 1С; порядок обработчиков задан явной сортировкой, где он важен; реакции идемпотентны и защищены от рекурсии; каждый обработчик начинается с точки логирования. Соблюдая эти правила, вы получаете гибкую бизнес-логику, которая переживает обновления платформы, не проседает под нагрузкой и понятна следующему разработчику. Если нужна такая архитектура под ваш проект, мы выстраиваем её в рамках highload-проектов на Битриксе и корпоративных сайтов на Битриксе. Другие материалы по архитектуре и производительности собраны в рубрике «Разработка и архитектура».