Менеджер меняет стадию сделки на «Успешная», а внешняя учётная система узнаёт об этом только на следующей синхронизации — если она вообще настроена по расписанию, а не руками раз в день. Разработчик в такой ситуации почти всегда идёт искать обработчик события OnCrmDealUpdate в Битрикс24: по названию кажется, что это ровно то событие, которое срабатывает в момент сохранения сделки. На практике в коробочном модуле crm события с таким именем нет — есть похожее, но другое, и путаница здесь стоит часов отладки, если не разобраться сразу. В статье разберём, какое событие реально ловить в коробке через AddEventHandler, что приходит в параметрах обработчика, чем момент до сохранения отличается от момента после, и почему обновление сделки внутри собственного обработчика почти гарантированно приводит к бесконечному циклу.
Что понадобится перед началом
- Доступ к файловой системе портала — минимум к
/bitrix/php_interface/init.php, а лучше к своему кастомному модулю, если правки не разовые - Права администратора на портале (для проверки поведения на реальных сделках)
- PHP 8.0 и новее и базовое знание объектной модели CRM —
CCrmDeal, работа с массивами полей - Тестовый портал или тестовая копия — зацикливание обработчика лучше поймать не на проде
- Понимание, что
init.phpвыполняется на каждый хит движка, поэтому логика в нём должна быть лёгкой, без тяжёлых запросов при каждой загрузке страницы - Доступ к логам PHP (
error_logили встроенный монитор ошибок в админке) — при отладке зацикливания без логов вы будете гадать вслепую
Основная часть: работаем с событием OnCrmDealUpdate в Битрикс24
Сначала — та самая путаница в названии, из-за которой половина форумных тем про эту тему выглядит как разговор на разных языках. В официальном списке событий модуля crm событие для изменения сделки называется OnAfterCrmDealUpdate — оно срабатывает после того, как CCrmDeal::Update уже записал изменения в базу. Имя OnCrmDealUpdate (в документации REST API — ONCRMDEALUPDATE) закреплено за другим механизмом: исходящим событием для внешних приложений, которое подписывается методом event.bind, доставляется POST-запросом на URL приложения и не имеет никакого отношения к AddEventHandler внутри портала. Если вы правите код коробки или пишете свой модуль — нужен именно OnAfterCrmDealUpdate (и его пара OnBeforeCrmDealUpdate для момента до сохранения). Дальше по тексту, когда говорим про обработчик в PHP-коде портала, речь именно о них.
Оба события живут в модуле crm без изменений много лет и остаются рабочими в актуальных сборках коробки — классические сущности CRM (сделка, лид, контакт, компания) на новый событийный слой модуля main не переводили. Отдельная история — смарт-процессы, пользовательские CRM-сущности, которые создаются через конструктор в интерфейсе: у них своя модель событий, завязанная на фабрику сущностей, и код из этой статьи для них напрямую не подойдёт. Если работаете именно со смарт-процессом, а не с классической сделкой — это стоит проверить отдельно, прежде чем переносить примеры один в один.
Шаг 1. Регистрируем обработчик в init.php модуля
В коробочной версии обработчики локальных событий модуля crm подключаются либо в /bitrix/php_interface/init.php портала, либо в init.php собственного модуля — второй вариант надёжнее, потому что не потеряется при обновлении ядра. Классический способ регистрации — функция AddEventHandler, три аргумента: модуль-источник, имя события, колбэк. Есть и более новый EventManager::addEventHandlerCompatible с той же сигнатурой обработчика — по факту оба варианта до сих пор рабочие и встречаются в реальных проектах примерно поровну.
<?php
// /bitrix/php_interface/init.php
// Классический способ регистрации обработчика локального события модуля
AddEventHandler(
'crm', // модуль-источник события
'OnAfterCrmDealUpdate', // имя события — именно так, без "Before" и без REST-варианта
'HandleDealUpdate'
);
// Функция-обработчик получает массив полей сделки по ссылке
function HandleDealUpdate(&$arFields)
{
// тело обработчика — см. шаг 2
}
Через EventManager регистрация той же пары выглядит так: \Bitrix\Main\EventManager::getInstance()->addEventHandlerCompatible('crm', 'OnAfterCrmDealUpdate', 'HandleDealUpdate'); — сигнатура функции-обработчика не меняется, разница только в способе подписки. Дальше по коду примеров используем классический AddEventHandler, он короче в тексте статьи.
Частая ошибка на этом шаге: опечатка в имени модуля-источника («Crm» вместо «crm» или регистрация как события модуля «main»). Обработчик в этом случае просто никогда не сработает, без единой строчки в логе ошибок — событие с таким именем в этом модуле не существует, и Bitrix молча его игнорирует.
Шаг 2. Смотрим, что приходит в параметрах события
$arFields — это массив полей сделки, переданный по ссылке. ID сделки в нём присутствует всегда, это ключевое поле события. А вот остальные поля — не факт: массив содержит только те поля, которые реально участвовали в конкретном вызове CCrmDeal::Update, а не полный набор полей сделки из базы.
function HandleDealUpdate(&$arFields)
{
// ID сделки — единственное поле, которое гарантированно есть в событии
$dealId = (int)$arFields['ID'];
// остальные поля могут отсутствовать, если не менялись в этом конкретном вызове
if (isset($arFields['STAGE_ID'])) {
$newStage = $arFields['STAGE_ID'];
}
// если нужен полный набор полей сделки, а не только изменённые — грузим отдельно
$deal = \CCrmDeal::GetByID($dealId);
if ($deal) {
$opportunity = $deal['OPPORTUNITY'];
}
}
Частая ошибка: обращаются к $arFields['OPPORTUNITY'] напрямую без isset. Работает нормально до первого обновления сделки, где сумма не передавалась — тогда в логе появляется notice, а логика молча получает null там, где ждала число.
Шаг 3. OnBeforeCrmDealUpdate или OnAfterCrmDealUpdate — выбираем нужный момент
OnBeforeCrmDealUpdate вызывается до записи в базу, получает новые значения полей (ещё не сохранённые) и может их менять — массив передаётся по ссылке. Вернув false из обработчика, можно полностью отменить обновление сделки, только текст ошибки нужно положить в $arFields['RESULT_MESSAGE'] заранее, иначе пользователь увидит просто «не удалось сохранить» без объяснений.
OnAfterCrmDealUpdate вызывается, когда сделка уже физически лежит в базе с новыми значениями. Для валидации и запрета изменений он бесполезен — поздно, изменения уже применились. Зато он безопаснее для побочных эффектов: логирование, уведомления, синхронизация с внешними системами, где важно, что состояние сделки уже финальное, а не промежуточное.
AddEventHandler('crm', 'OnBeforeCrmDealUpdate', 'ValidateDealUpdate');
function ValidateDealUpdate(&$arFields)
{
// Пример: запрещаем менять сумму уже закрытой сделки
if (!isset($arFields['ID'], $arFields['OPPORTUNITY'])) {
return true;
}
$current = \CCrmDeal::GetByID($arFields['ID']);
if ($current && $current['STAGE_ID'] === 'WON') {
// сделка уже была завершена — блокируем изменение суммы задним числом
$arFields['RESULT_MESSAGE'] = 'Нельзя менять сумму завершённой сделки';
return false;
}
return true;
}
Обратите внимание: в OnBeforeCrmDealUpdate старое состояние сделки приходится запрашивать отдельно через GetByID, потому что в параметрах события — только новые значения. Это легко упустить и сравнивать «новое с новым».
На практике return false из OnBeforeCrmDealUpdate используют реже, чем кажется по документации — это довольно грубый инструмент, пользователь просто не сможет сохранить сделку, и ему нужно точно понимать, почему. Чаще логика мягче: не блокировать сохранение, а скорректировать значение поля прямо в $arFields (например, округлить сумму до целых или подставить дефолтную ответственную сторону, если поле пустое) — раз массив передан по ссылке, изменения долетят до записи в базу без дополнительного вызова Update.

Шаг 4. Пример использования: реагируем на смену стадии сделки
Типичная задача — не реагировать на любое обновление сделки (их за день может быть сотни), а именно на смену стадии. Если то же самое нужно сделать снаружи портала, через REST, смотрите разбор обновления сделок через REST API. Проверка через isset здесь не формальность, а единственный способ не запускать логику зря.
AddEventHandler('crm', 'OnAfterCrmDealUpdate', 'NotifyExternalSystemOnStageChange');
function NotifyExternalSystemOnStageChange(&$arFields)
{
// реагируем только на изменение стадии, а не на любое обновление сделки
if (!isset($arFields['STAGE_ID']) || $arFields['STAGE_ID'] !== 'WON') {
return;
}
// отправка во внешнюю систему — например, в учётную или складскую
$httpClient = new \Bitrix\Main\Web\HttpClient();
$httpClient->setHeader('Content-Type', 'application/json');
$httpClient->post(
'https://erp.example.com/api/deals/notify',
json_encode(['deal_id' => (int)$arFields['ID'], 'stage' => $arFields['STAGE_ID']])
);
}
Частая ошибка: отправляют синхронный HTTP-запрос прямо внутри обработчика. Пока внешняя система отвечает — сохранение сделки в интерфейсе у менеджера просто зависает. Если запрос уходит в систему, которая иногда «думает» по 5-10 секунд, лучше класть задачу в агент (CAgent) или очередь и обрабатывать асинхронно, а не держать пользователя перед крутящимся индикатором.
Шаг 5. Достаём значения полей до изменения, если нужно сравнение
Отдельный случай — когда важно не просто «стадия равна WON», а именно «стадия изменилась на WON», в отличие от повторного сохранения той же сделки без реальных изменений (бывает при массовом пересчёте через бизнес-процессы). К моменту OnAfterCrmDealUpdate старое значение уже перезаписано в базе, поэтому его нужно запомнить раньше — в OnBeforeCrmDealUpdate.
AddEventHandler('crm', 'OnBeforeCrmDealUpdate', 'RememberOldStage');
AddEventHandler('crm', 'OnAfterCrmDealUpdate', 'CompareStageChange');
function RememberOldStage(&$arFields)
{
if (!empty($arFields['ID'])) {
$current = \CCrmDeal::GetByID($arFields['ID']);
if ($current) {
// храним старое значение в рамках текущего запроса
$GLOBALS['DEAL_OLD_STAGE'][$arFields['ID']] = $current['STAGE_ID'];
}
}
return true;
}
function CompareStageChange(&$arFields)
{
$dealId = (int)$arFields['ID'];
$oldStage = $GLOBALS['DEAL_OLD_STAGE'][$dealId] ?? null;
$newStage = $arFields['STAGE_ID'] ?? null;
if ($oldStage !== null && $newStage !== null && $oldStage !== $newStage) {
// именно здесь произошла реальная смена стадии, а не пересохранение тех же данных
}
}
Способ рабочий, но не идеальный — хранение через $GLOBALS живёт только в пределах одного запроса, что для этой задачи обычно и требуется, поскольку оба события вызываются в рамках одного вызова CCrmDeal::Update.
Шаг 6. Защищаемся от бесконечного цикла при обновлении сделки внутри обработчика
Вот та самая ошибка, из-за которой обработчики этого события чаще всего попадают в закладки форумов поддержки. Если внутри OnAfterCrmDealUpdate вызвать CCrmDeal::Update для той же сделки (например, чтобы записать обратно вычисленное значение в UF-поле) — это снова запускает OnBeforeCrmDealUpdate и OnAfterCrmDealUpdate для той же сделки. Обработчик вызывает сам себя. Без защиты процесс упирается либо в max_execution_time, либо в memory_limit — отдельного лимита на глубину рекурсии в PHP нет, поэтому вложенные вызовы просто съедают время и память, пока запрос не упадёт. В логе это выглядит как fatal error без всякого упоминания события, и связать его с обработчиком с первого раза непросто.
function HandleDealUpdateSafe(&$arFields)
{
// статический флаг защищает от повторного входа для той же сделки
static $processing = [];
$dealId = (int)$arFields['ID'];
if (!empty($processing[$dealId])) {
return; // уже обрабатываем эту сделку — выходим, чтобы не зациклиться
}
$processing[$dealId] = true;
if (isset($arFields['STAGE_ID']) && $arFields['STAGE_ID'] === 'WON') {
$deal = new \CCrmDeal(false);
// CCrmDeal::Update принимает $arFields по ссылке — литерал массива сюда не передать,
// PHP выдаст fatal error, поэтому сначала кладём значения в переменную
$updateFields = ['UF_CLOSED_BY_HANDLER' => 'Y'];
// этот вызов Update заново поднимет OnAfterCrmDealUpdate,
// но флаг processing[$dealId] не даст логике выполниться второй раз
$deal->Update($dealId, $updateFields);
}
unset($processing[$dealId]);
}
Статический массив живёт в рамках одного PHP-процесса — этого достаточно, потому что рекурсивный вызов происходит в том же самом HTTP-запросе, а не в отдельном.

Частые ошибки и как их избежать
Путают REST-событие ONCRMDEALUPDATE и локальное OnAfterCrmDealUpdate. Возникает из-за похожего названия и того, что документация для них лежит в разных разделах: события REST описаны отдельно от событий модуля коробки.
По запросу «OnCrmDealUpdate» в поиске чаще всплывает как раз REST-версия, и разработчик копирует пример подписки через event.bind, а потом недоумевает, почему обработчик не срабатывает в коде портала. Решение: если код выполняется внутри портала через init.php — нужен OnAfterCrmDealUpdate/OnBeforeCrmDealUpdate. ONCRMDEALUPDATE нужен только внешним приложениям без прямого доступа к файловой системе портала — как это устроено, разбирали в статье про вебхуки и обработку событий.
Обновление сделки внутри своего же обработчика без защиты от повторного входа. Самая частая причина бесконечного цикла — описана в шаге 6. Решение: статический флаг по ID сделки или, если логика сложнее, проверка через явный признак «эта операция уже выполнена» — например, отдельное служебное поле, которое обработчик проверяет перед тем, как снова лезть в Update.
Не проверяют isset() при чтении полей из $arFields. Массив события содержит только изменённые поля, а не весь набор полей сделки. При первом тесте разработчик обычно меняет сразу все поля через форму — массив полный, ошибок не видно. А в проде интеграции часто обновляют сделку точечно, одним полем, и вот тут «работавший на тесте» код падает. Решение: всегда проверять наличие ключа перед обращением, а для полного состояния сделки — грузить её отдельно через CCrmDeal::GetByID.
Логику держат в общем init.php портала, а не в модуле. Работает, но плохо переживает миграции и передачу проекта другому разработчику — через полгода никто не помнит, что вот этот блок кода в php_interface отвечает за интеграцию со складом, и при следующей чистке init.php его случайно затирают вместе с чем-то действительно лишним. Решение: для устойчивой логики — отдельный модуль с install/index.php, который сам регистрирует обработчик при установке.
Из практики А2
На одном проекте с оптовой торговой компанией стояла задача: при переводе сделки в статус «Успешная» резервировать товар на складе через API 1С. Первая версия обработчика писала ID резерва обратно в UF-поле сделки прямо внутри OnAfterCrmDealUpdate — без защиты от повторного входа. Сделка при сохранении «подвисала» на пару секунд в интерфейсе менеджера, а в логе PHP иногда проскакивал fatal error про превышение времени выполнения. Разобрались через трассировку вызовов: HandleDealUpdate → CCrmDeal::Update → OnAfterCrmDealUpdate → HandleDealUpdate повторялось, пока не упиралось в лимит. После добавления флага по ID сделки и переноса самого HTTP-запроса к 1С в отдельный агент вместо прямого вызова — сохранение сделки в интерфейсе стало мгновенным, а резервирование происходило с небольшой задержкой, но предсказуемо и без падений. Отдельный урок с того проекта: подобные вещи стоит сначала гонять на копии портала с тестовыми сделками и заведомо «плохими» сценариями — массовым изменением стадии через список сделок, например, — потому что именно там бесконечный цикл проявляется быстрее всего, а не на одиночном сохранении карточки.
Итог
Теперь понятно, какое событие в коробочном Битрикс24 реально отвечает за отслеживание изменений сделки в PHP-коде портала — OnAfterCrmDealUpdate с парой OnBeforeCrmDealUpdate — и как не наступить на грабли с бесконечным циклом при обновлении сделки внутри собственного обработчика. Если на каком-то шаге застряли или обработчик ведёт себя не так, как в примерах — опишите ситуацию в комментарии или напишите нам напрямую.