01 01234567890 01234567890

Обработчик событий изменения сделки в Битрикс24: практические примеры

Битрикс24 API 03 сентября 2026
~3 минуты чтения 3 657 символов 2 просмотра

Какое событие ловить в коробке, чем момент до записи отличается от момента после и как не уйти в бесконечный цикл при обновлении сделки

Менеджер меняет стадию сделки на «Успешная», а внешняя учётная система узнаёт об этом только на следующей синхронизации — если она вообще настроена по расписанию, а не руками раз в день. Разработчик в такой ситуации почти всегда идёт искать обработчик события 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 — и как не наступить на грабли с бесконечным циклом при обновлении сделки внутри собственного обработчика. Если на каком-то шаге застряли или обработчик ведёт себя не так, как в примерах — опишите ситуацию в комментарии или напишите нам напрямую.

Азамат

Азамат

Основатель «А Квадрат»

Работаю в web-деве с 2014 года. Прошел путь от фрилансера до фрилансера-руководителя))

Остались вопросы? Мы можем помочь!

Интернет-магазин для малого бизнеса: с чего начать и сколько это стоит
Следующая статья 02.09.2026