Кастомизация Битрикс24: 3 способа и что переживёт апдейт

Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про то, что происходит с доработками корпоративного портала в момент обновления платформы: почему из трёх честных способов кастомизации один умирает молча, второй ковыляет, а третий апдейта почти не замечает.

Пятница, вечер. Раздел с графиком отсутствий на портале «побелел» — не упал с ошибкой, а просто отдал пустую страницу. HR пишет в общий чат, что не может поставить человеку отпуск. Разбирались часа полтора, пока не нашли причину: двумя годами раньше кто‑то скопировал шаблон штатного компонента, подкрасил его под фирменные цвета и на этом успокоился. Всё это время копия жила своей жизнью. Обновление поменяло структуру данных, которую шаблон получал на вход, и вежливо об этом не предупредило.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС. PHP — не мой основной стек, и это тот случай, когда взгляд со стороны помогает: вопрос здесь не про PHP. Как встроить свою логику в платформу, которая обновляется без твоего участия и без твоего разрешения? Хук, наследник или патч поверх — на Spring этот спор идёт двадцать лет, в экосистемах WordPress и Drupal он же, только словами попроще. Битрикс24 отличается тем, что показывает цену ошибки нагляднее всех.

Сегодня разберём это на одном конкретном компоненте. Возьмём реальную задачу, сделаем её тремя способами — копией шаблона, наследником и обработчиком события, — а потом посмотрим, что случится с каждым вариантом при обновлении. В конце будет скрипт, которым свой портал можно проверить за пять минут, и короткий чек‑лист.

Рис. 1. Три способа пристроиться к чужой платформе и один проходящий апдейт

Рис. 1. Три способа пристроиться к чужой платформе и один проходящий апдейт

Объект разбора: почему график отсутствий — честный полигон

За графиком отсутствий в коробочном Битрикс24 стоит компонент bitrix:intranet.absence.calendar. Он рисует календарь отсутствий сотрудников с вкладками «день», «неделя», «месяц», умеет фильтровать по подразделению и типу события. Сам он занимается правами, параметрами и фильтром, а отрисовку сетки делегирует служебному компоненту‑сателлиту — intranet.absence.calendar.view. Данные лежат не в отдельной таблице, а в инфоблоке, идентификатор которого приходится доставать из настроек модуля: Option::get('intranet', 'iblock_absence').

Мне как‑то попалась в документации фраза, что идентификатор этого инфоблока у каждого сайта в рамках одной установки может быть свой. Отличная иллюстрация того, с чем мы имеем дело. Компонент, служебный подкомпонент, шаблон, модуль и хранилище на инфоблоках, которое ещё и настраивается на уровне сайта. Пять слоёв, и апдейт способен поменять любой из них.

Задачу возьмём приземлённую. Классическая боль: в отделе одновременно уходят в отпуск двое из трёх человек с одинаковой ролью, и подразделение встаёт. Нужно, чтобы такие даты подсвечивались: если в один день в подразделении отсутствует больше N человек, день помечается как перегруженный, а руководителю летит уведомление.

Весь код ниже — демонстрационный. Он показывает точку вмешательства и ход мысли, а не готовое к продакшену решение: имена методов и ключей у вас будут свои, их нужно сверять со своей версией ядра.

На рис. 2 — не «как устроен Битрикс», а три конкретные точки, в которые физически можно вклиниться со своей логикой. Именно выбор точки, а не объём написанного кода, определяет, что с вами случится при обновлении.

Рис. 2. Схема принципиальная: слои графика отсутствий и три точки вмешательства

Рис. 2. Схема принципиальная: слои графика отсутствий и три точки вмешательства

Главное, что стоит унести из схемы: чем глубже точка, тем стабильнее контракт. Шаблон — это внутреннее устройство чужого кода, сохранять его никто не обязан. Документированные события живут заметно дольше, хотя и они не вечны.

Способ 1. Копия шаблона: быстро, дёшево и с датой смерти

Самый короткий путь, и именно поэтому самый популярный. Копируем штатный шаблон в свой каталог и правим.

Есть нюанс, на котором регулярно спотыкаются. Чтобы система увидела вашу копию, структура в /local должна повторять структуру шаблона сайта, а сам шаблон сайта в /local должен быть построен на штатном шаблоне bitrix24. Иначе вы правите файл, обновляете страницу и не понимаете, почему ничего не изменилось.

Листинг 1. Наш result_modifier.php, который считает перегруз по подразделениям:

<?php
// /local/templates/bitrix24/components/bitrix/intranet.absence.calendar/hr_overload/result_modifier.php
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

use OtusHrDepartmentProvider;

$limit = 2;
$perDay = [];

// подразделение у отсутствия не хранится, оно берётся у сотрудника:
// свой провайдер, который отдаёт карту [USER_ID => DEPARTMENT_ID]
$departments = DepartmentProvider::getMap();

// ключ с записями не документирован: смотрим реальный дамп $arResult
// на своей версии ядра. Так делаем только тогда, когда подходящего
// публичного API нет или в нём нет нужных нам полей
foreach ((array)($arResult['ENTRIES'] ?? []) as $entry) {
    $userId = (int)($entry['USER_ID'] ?? 0);
    $day    = (string)($entry['DATE_FROM'] ?? '');
    if ($userId === 0 || $day === '') {
        continue;
    }

    $dept = (int)($departments[$userId] ?? 0);
    if ($dept === 0) {
        continue;
    }

    $perDay[$day][$dept] = ($perDay[$day][$dept] ?? 0) + 1;
}

$arResult['OVERLOADED'] = [];
foreach ($perDay as $day => $byDept) {
    foreach ($byDept as $dept => $count) {
        if ($count > $limit) {
            $arResult['OVERLOADED'][$day][] = $dept;
        }
    }
}

Дальше в template.php дописываем класс к ячейке, в style.css красим её в мягкий оранжевый. Работает. Полчаса работы, демонстрация заказчику в тот же день.

А что произошло на самом деле: мы взяли на себя обязательство поддерживать форк чужого шаблона. Ключ с записями приходится подбирать по дампу, потому что это не контракт, а деталь реализации. Но неприятнее другое: апдейт принесёт новую версию оригинала, а пользователь её не увидит. Он останется на копии двухлетней давности вместе с багами, которые в оригинале давно исправили, и без новых возможностей, которые туда приехали.

Битрикс на этот счёт высказывается прямо: шаблоны корпоративного портала кастомизировать не следует. Технически скопировать можно, только обратную совместимость вы при этом теряете.

Значит ли это, что копия — всегда зло? Нет. Копия остаётся рабочим инженерным компромиссом при одном условии: вы ведёте её осознанно и после каждого обновления сверяете с оригиналом, перенося изменения к себе. Проблема не в копировании, а в копировании по умолчанию и без последующего сопровождения. Именно так рождаются «побелевшие» разделы.

Сателлит добавляет отдельный сюрприз. Разметку строит intranet.absence.calendar.view, а параметры и фильтр — родитель. Скопировав шаблон одного, вы получаете половину картины и рано или поздно лезете за вторым. Так один аккуратный форк превращается в два несинхронизированных.

Способ 2. Наследник или обёртка: разметка обновляется, логика ваша

Первое, что я делаю, прежде чем говорить о наследовании: иду в /bitrix/components/bitrix/intranet.absence.calendar/ и смотрю, есть ли там class.php. Это не формальность. Компоненты старого поколения написаны процедурно, вся логика лежит в component.php, и наследовать там просто нечего.

Если class.php есть, картина приятнее. При инициализации компонента ядро подключает этот файл и берёт из него последний по порядку класс, унаследованный от CBitrixComponent. Кстати, отсюда же следует известная особенность: цепочка из нескольких наследников в одном файле не даст ожидаемого результата, в работу пойдёт последний.

Но прежде чем радоваться, стоит проверить ещё четыре вещи, и любая из них может закрыть этот путь: класс объявлен final; нужный метод объявлен private; arResult собирается прямо в теле executeComponent() без отдельного метода подготовки; половина логики всё равно осталась в component.php. Если совпало хотя бы одно — идите в третий способ, не ломайте себе руки.

И главная ловушка, на которой я сам однажды потерял вечер. executeComponent() у большинства компонентов заканчивается вызовом includeComponentTemplate(). Дописывать arResult после parent::executeComponent()бессмысленно: шаблон к этому моменту уже отрисован, а повторный вызов приведёт ко второму рендеру. Вклиниваться нужно раньше — в тот метод, который готовит данные.

Листинг 2. Компонент‑наследник:

<?php
// /local/components/otus/absence.calendar/class.php
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

// точное имя родительского класса и метода подготовки данных берём
// из class.php штатного компонента, а не из головы
class OtusAbsenceCalendarComponent extends CIntranetAbsenceCalendarComponent
{
    public function onPrepareComponentParams($arParams): array
    {
        $arParams = parent::onPrepareComponentParams($arParams);
        $arParams['OVERLOAD_LIMIT'] = (int)($arParams['OVERLOAD_LIMIT'] ?? 2);

        return $arParams;
    }

    // переопределяем подготовку данных, а НЕ executeComponent():
    // родитель сам вызывает includeComponentTemplate() в конце,
    // и после него в arResult писать уже поздно
    protected function prepareResult(): void
    {
        parent::prepareResult();

        $this->arResult['OVERLOADED'] = $this->calculateOverload(
            (array)($this->arResult['ENTRIES'] ?? []),
            (int)$this->arParams['OVERLOAD_LIMIT']
        );
    }

    private function calculateOverload(array $entries, int $limit): array
    {
        // 1. сгруппировать записи по дню и подразделению
        // 2. отобрать дни, где счётчик превысил $limit
        // 3. вернуть массив вида [дата => [id подразделений]]
    }
}

Если подходящего метода нет, остаётся обёртка. И здесь важно не обманывать себя насчёт того, что она даёт. К моменту возврата из IncludeComponent() дочерний компонент уже отработал и отдал разметку в буфер вывода, его arResult вам недоступен. Обёртка управляет входом и обрамлением: подготовленными параметрами, своими ассетами, дополнительной разметкой вокруг.

Листинг 3. Компонент‑обёртка:

<?php
// /local/components/otus/absence.calendar/templates/.default/template.php

// свои стили и скрипты, которые лягут поверх штатной разметки
$APPLICATION->SetAdditionalCSS('/local/assets/absence-overload.css');

$APPLICATION->IncludeComponent(
    'bitrix:intranet.absence.calendar',
    '',
    $arParams,     // параметры мы уже посчитали в своём class.php
    $component     // вложенность важна: кеш и права наследуются корректно
);

Постобработать чужой HTML технически можно, обернув вызов в ob_start() и ob_get_clean(). Но это уже разбор разметки регулярками, и хрупкость тут не меньше, чем у копии шаблона. В этой ситуации я бы честно признал, что задача решается не здесь.

Чем наследник лучше копии: своя логика лежит в своём пространстве имён, оригинальный шаблон продолжает обновляться, новую разметку после апдейта вы получаете бесплатно. Чем хуже: вы всё ещё завязаны на внутренности родителя. Я бы предпочёл этот способ первому во всех случаях, когда без вмешательства в вывод не обойтись, но иллюзий не питаю — это наследование от кода, который никто не проектировал как публичный API.

Способ 3. События: доработка, которая не знает о компоненте

Развернём задачу. Нам ведь на самом деле нужно не «покрасить ячейку», а «узнать, что в отделе перегруз, и сообщить об этом». Подсветку в календаре надо ещё пойти и увидеть, а уведомление придёт само.

Отсутствия хранятся в инфоблоке, а у инфоблоков есть документированные события. Значит, можно перехватить сам факт появления отсутствия и не трогать компонент вообще.

Листинг 4. Регистрация обработчика:

<?php
// /local/php_interface/init.php
BitrixMainEventManager::getInstance()->addEventHandler(
    'iblock',
    'OnAfterIBlockElementAdd',
    ['\Otus\Hr\AbsenceOverloadHandler', 'onAbsenceSaved']
);

Листинг 5. Сам обработчик:

<?php
// /local/modules/otus.hr/lib/absenceoverloadhandler.php
namespace OtusHr;

use BitrixMainConfigOption;

class AbsenceOverloadHandler
{
    // сигнатура с & обязательна: события инфоблоков передают поля
    // по ссылке на исходную переменную. В OnBefore-событиях этим правят
    // данные до записи, в OnAfter правка на запись уже не влияет,
    // но несовпадение сигнатуры сломает вызов
    public static function onAbsenceSaved(&$fields): void
    {
        $absenceIblockId = (int)Option::get('intranet', 'iblock_absence', '-1');

        if ($absenceIblockId <= 0 || (int)($fields['IBLOCK_ID'] ?? 0) !== $absenceIblockId) {
            return;
        }
        if (empty($fields['RESULT'])) {
            return; // добавление отменили выше по стеку
        }

        // считаем пересечения по подразделению и, если лимит превышен,
        // отправляем уведомление руководителю через штатный API
    }
}

Обратите внимание на две проверки в начале. Первая — что мы в нужном инфоблоке: обработчик вешается на весь модуль, а не на конкретную сущность, и без фильтра ваш код будет выполняться при каждом сохранении любого элемента на портале.

Вторая проверка объясняется хуже и потому чаще пропускается. Событие OnAfterIBlockElementAdd вызывается в любом случае — даже если на предыдущем шаге, в OnBefore, добавление отменили исключением. Различить это можно только по полю RESULT: при успехе там лежит идентификатор созданного элемента, при отмене — false.

Без этой проверки вы будете рассылать уведомления об отсутствиях, которых не существует. Мой вариант, который я обычно использую, — обе проверки первыми строками и ранний выход, до любых обращений к базе.

Визуальную часть можно вынести в отдельное расширение: свой JS и CSS, подключаемые только на странице графика и работающие поверх штатной разметки. Скажу честно, это тоже хрупко, DOM меняется. Но ломается локально: не сработала подсветка, а не белый экран на весь раздел.

Оговорюсь и здесь, чтобы не создавать ложного ощущения безопасности. События — самая стабильная из трёх точек, но не вечная: между крупными версиями меняются и сигнатуры обработчиков, и момент вызова, и семантика полей. Разница с копией шаблона количественная, а не качественная: событие переживает годовые обновления, скопированный шаблон — часто нет.

Если доработок больше двух‑трёх, оформляйте их не россыпью в init.php, а собственным модулем в /local/modules/, который сам регистрирует свои обработчики при установке. По сути та же идея, что со стартерами в Spring: доработка живёт как отдельная единица со своим жизненным циклом.

Про кеш, о котором все вспоминают слишком поздно

Способ вмешательства меняет не только выживаемость, но и производительность, и об этом стоит подумать до, а не после.

result_modifier.php из листинга 1 выполняется только тогда, когда компонент пересобирает кеш. При валидном кеше он не отработает вовсе, а результат приедет готовым — то есть считать там тяжёлые вещи относительно безопасно, но и рассчитывать на актуальность данных «прямо сейчас» нельзя.

component_epilog.php, наоборот, исполняется при каждом вызове компонента независимо от кеша, и это единственное место в шаблоне, где уместна логика, зависящая от текущего пользователя или момента времени. Данные между ними передаются не напрямую: чтобы ключ из arResult доехал до эпилога, его нужно явным образом добавить в кеш компонента через SetResultCacheKeys().

Наследник наследует и кеш родителя, поэтому забытый вызов сброса приводит к классике жанра: код исправили, а на портале ничего не изменилось. Обработчик события живёт вне кеша компонента совсем — за это приходится платить тем, что он выполняется на каждом сохранении, и потому обязан быть дешёвым на входе. Отдельно замечу: если у вас Bitrix Framework с включённым композитным режимом (на корпоративных порталах это редкость, но встречается), любая логика, завязанная на серверный рендер, ведёт себя иначе — там разметка отдаётся из статического кеша, и вклиниваться приходится уже на стороне браузера.

Проверка обновлением: что случилось в реальности

Теперь самое интересное — что происходит с тремя вариантами, когда платформа едет вперёд.

В июне 2026 года разработчик Битрикс24 Дмитрий Черкашин опубликовал разбор того, что чаще всего ломается в кастомизациях коробки после перехода на ORM и PHP 8. Минимальной версией PHP для Битрикс24 стала 8.0 ещё в 2023 году, и миграция для многих оказалась холодным душем. Причина не в экзотике, а в бытовухе: то, что PHP 7.4 прощал как предупреждение, PHP 8 роняет фатально. Функции семейства array_*, получив null вместо массива, теперь бросают TypeError.

Дальше — прямое попадание в наш случай. В разборе приводится типичный фрагмент из шаблона компонента, где $arResult['ITEMS'] берётся без проверки, а потом уходит в count(), foreach, array_search() и usort(). Автор насчитывает там минимум пять проблемных мест на тринадцать строк. Годами этот код работал, потому что данные приходили ожидаемого вида.

Отдельно отмечено, что болезненнее всего проблема проявилась именно в старых кастомизациях — в шаблонах, компонентах и модулях, где данные годами использовались без проверок.

Как и предполагал: пострадали не те, кто вешался на события, а те, кто скопировал шаблон и забыл про него. Обработчик работает с массивом полей, который приходит из ядра и проверяется вами же на входе. Скопированный шаблон работает с arResult, который формирует чужой код и меняет тогда, когда сочтёт нужным.

Кстати, из того же разбора я утащил себе полезную вещь — готовый промпт для AI‑проверки кода под Битрикс с перечислением классов ORM и типовых ловушек PHP 8. Прогнать через него старые шаблоны перед апдейтом заметно дешевле, чем читать их глазами.

Что из этого переживает обновление

Критерий

Копия шаблона

Наследник / обёртка

События

Скорость первой реализации

Часы

День

День‑два

На что завязаны

Внутренняя разметка и arResult

Методы и arResult родителя

Документированные события модуля

Что даёт обновление

Копия «замерзает», новые фичи не приходят

Разметка обновляется, логика ваша

Обновляется всё, кроме вашего кода

Радиус поражения при поломке

Весь раздел

Ваш компонент

Ваш обработчик

Отношение к кешу

Внутри кеша компонента

Наследует кеш родителя

Вне кеша, выполняется всегда

Диагностируемость

Плохая: белый экран без следов

Средняя

Хорошая: своя точка входа, свои логи

Когда оправдан

Разовая косметика с датой смерти

Нужен другой вывод, событий не хватает

Логика, права, уведомления, интеграции

Три способа — не весь список

Чтобы не создалось ложного впечатления: копия, наследник и событие — это точки внутри самого портала, и ими выбор не исчерпывается. Рядом лежат REST и приложения с интерфейсными встройками, собственные JS‑расширения, агенты для фоновой обработки и консольные команды. Часто задача, которая выглядит как «доработать компонент», на деле решается вообще за его пределами, и это лучший из возможных исходов: код, который не знает о существовании компонента, невозможно сломать его обновлением.

Как выбирать: план действий

Дальше — короткая инструкция, к которой я свёл всё вышесказанное. На рис. 3 не «правильный ответ», а порядок вопросов, которые стоит задать себе до того, как открыть редактор.

Рис. 3. План действий: порядок вопросов при выборе способа доработки

Рис. 3. План действий: порядок вопросов при выборе способа доработки

Главная мысль схемы: способ выбирается не по сложности задачи, а по природе изменения. «Покрасить ячейку» и «не дать отделу остаться без людей» — задачи из разных слоёв, и решать их одним копированием шаблона нельзя.

Выбор точки расширения — только одна из задач разработчика Битрикс24. Вступительный тест покажет, какие вопросы уже не вызывают затруднений, а что стоит разобрать глубже.

Что делают команды, у которых обновление стало рутиной: практики лета 2026

Собрал то, что вижу у команд, где апдейт коробки проходит буднично.

Весь свой код — только в /local, в /bitrix не трогается ничего и никогда. Доработки оформляются модулем, а init.php остаётся тонким: он только регистрирует обработчики. Ведётся реестр доработок — обычный файл в репозитории, где на каждую запись указано: что сделано, в какой точке, зачем и на какой версии ядра проверено. Апдейт сначала накатывается на копию контура, после него прогоняется смоук строго по списку из реестра. И отдельным пунктом с прошлого года — прогон старых шаблонов и модулей через AI‑ревью перед обновлением, с акцентом на непроверенные обращения к массивам.

Ещё один приём, который дёшево делается и дорого окупается. Если копии шаблонов у вас уже есть (а они почти наверняка есть), заведите сравнение с оригиналом.

Листинг 6 (bash). Инвентаризация копий штатных шаблонов:

# все места, где мы форкнули шаблон стандартного компонента
find /local/templates -type d -path "*/components/bitrix/*" -mindepth 4 | sort

# зафиксировали состояние оригиналов на текущей версии ядра
find /bitrix/components/bitrix -name "template.php" 
  -exec md5sum {} ; | sort -k2 > /root/bx-templates.baseline

# после обновления смотрим, какие оригиналы уехали
find /bitrix/components/bitrix -name "template.php" 
  -exec md5sum {} ; | sort -k2 | diff /root/bx-templates.baseline -

Пять минут после каждого апдейта — и о расхождении вы узнаёте раньше, чем HR‑директор.

Где этот вывод не универсален

  • Первое: всё сказанное относится к коробочной версии. В облаке компонентов и шаблонов у вас просто нет, там работают приложения и встройки через REST.

  • Второе: события не всесильны. Далеко не на всё в портале есть подходящее событие, и бывают задачи, где без вмешательства в вывод не обойтись физически.

  • Третье: наблюдения про PHP 8 и ORM я привожу по разбору коллег из Битрикс24, а не по своим замерам. На вашем контуре набор граблей будет свой.

Что можно проверить у себя прямо сейчас

Могу себе представить, как это читает человек, у которого на портале лежит десяток унаследованных доработок неизвестного происхождения. Тогда порядок такой:

  1. Прогнать листинг 6 и получить список всех форков штатных шаблонов. Обычно их находится больше, чем ожидалось.

  2. По каждому ответить на один вопрос: это правка вывода или правка логики? Всё, что оказалось логикой, — кандидат на переезд в события.

  3. Проверить копии на совместимость с PHP 8: каждое обращение к массиву из arResult должно быть защищено через?? [] или приведением к массиву.

  4. Завести реестр доработок, если его нет. Даже пустой файл лучше, чем знание, живущее в голове у одного человека.

  5. Следующее обновление накатить сначала на копию и пройти по реестру руками.

Разница между «у нас коробка, мы боимся обновляться» и «у нас коробка, обновляемся по расписанию» — это не размер команды и не бюджет. Это выбор точки, в которую вы вклиниваетесь в чужую платформу, сделанный один раз и осознанно.

Кастомизация Битрикс24: 3 способа и что переживёт апдейт - 4

Когда доработка должна пережить следующее обновление, важно не только решить текущую задачу, но и выбрать устойчивую точку расширения.

Продолжить разбор можно на бесплатных открытых уроках:

  • 3 августа, 20:00. «Кастомизация компонентов в Битрикс24». Записаться.
    разберём способы доработки компонентов на примере графика отсутствий и связанных с ним служебных компонентов.

  • 19 августа, 20:00. «Модуль CRM глазами разработчика: практическое руководство». Записаться.
    рассмотрим архитектуру CRM в Битрикс24 и возможности её адаптации под задачи бизнеса.

Автор: sproshchaev

Источник

Оставить комментарий