Кастомизация Битрикс24: 3 способа и что переживёт апдейт
Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про то, что происходит с доработками корпоративного портала в момент обновления платформы: почему из трёх честных способов кастомизации один умирает молча, второй ковыляет, а третий апдейта почти не замечает.
Пятница, вечер. Раздел с графиком отсутствий на портале «побелел» — не упал с ошибкой, а просто отдал пустую страницу. HR пишет в общий чат, что не может поставить человеку отпуск. Разбирались часа полтора, пока не нашли причину: двумя годами раньше кто‑то скопировал шаблон штатного компонента, подкрасил его под фирменные цвета и на этом успокоился. Всё это время копия жила своей жизнью. Обновление поменяло структуру данных, которую шаблон получал на вход, и вежливо об этом не предупредило.
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС. PHP — не мой основной стек, и это тот случай, когда взгляд со стороны помогает: вопрос здесь не про PHP. Как встроить свою логику в платформу, которая обновляется без твоего участия и без твоего разрешения? Хук, наследник или патч поверх — на Spring этот спор идёт двадцать лет, в экосистемах WordPress и Drupal он же, только словами попроще. Битрикс24 отличается тем, что показывает цену ошибки нагляднее всех.
Сегодня разберём это на одном конкретном компоненте. Возьмём реальную задачу, сделаем её тремя способами — копией шаблона, наследником и обработчиком события, — а потом посмотрим, что случится с каждым вариантом при обновлении. В конце будет скрипт, которым свой портал можно проверить за пять минут, и короткий чек‑лист.
Объект разбора: почему график отсутствий — честный полигон
За графиком отсутствий в коробочном Битрикс24 стоит компонент bitrix:intranet.absence.calendar. Он рисует календарь отсутствий сотрудников с вкладками «день», «неделя», «месяц», умеет фильтровать по подразделению и типу события. Сам он занимается правами, параметрами и фильтром, а отрисовку сетки делегирует служебному компоненту‑сателлиту — intranet.absence.calendar.view. Данные лежат не в отдельной таблице, а в инфоблоке, идентификатор которого приходится доставать из настроек модуля: Option::get('intranet', 'iblock_absence').
Мне как‑то попалась в документации фраза, что идентификатор этого инфоблока у каждого сайта в рамках одной установки может быть свой. Отличная иллюстрация того, с чем мы имеем дело. Компонент, служебный подкомпонент, шаблон, модуль и хранилище на инфоблоках, которое ещё и настраивается на уровне сайта. Пять слоёв, и апдейт способен поменять любой из них.
Задачу возьмём приземлённую. Классическая боль: в отделе одновременно уходят в отпуск двое из трёх человек с одинаковой ролью, и подразделение встаёт. Нужно, чтобы такие даты подсвечивались: если в один день в подразделении отсутствует больше N человек, день помечается как перегруженный, а руководителю летит уведомление.
Весь код ниже — демонстрационный. Он показывает точку вмешательства и ход мысли, а не готовое к продакшену решение: имена методов и ключей у вас будут свои, их нужно сверять со своей версией ядра.
На рис. 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 не «правильный ответ», а порядок вопросов, которые стоит задать себе до того, как открыть редактор.
Главная мысль схемы: способ выбирается не по сложности задачи, а по природе изменения. «Покрасить ячейку» и «не дать отделу остаться без людей» — задачи из разных слоёв, и решать их одним копированием шаблона нельзя.
Выбор точки расширения — только одна из задач разработчика Битрикс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, а не по своим замерам. На вашем контуре набор граблей будет свой.
Что можно проверить у себя прямо сейчас
Могу себе представить, как это читает человек, у которого на портале лежит десяток унаследованных доработок неизвестного происхождения. Тогда порядок такой:
-
Прогнать листинг 6 и получить список всех форков штатных шаблонов. Обычно их находится больше, чем ожидалось.
-
По каждому ответить на один вопрос: это правка вывода или правка логики? Всё, что оказалось логикой, — кандидат на переезд в события.
-
Проверить копии на совместимость с PHP 8: каждое обращение к массиву из arResult должно быть защищено через?? [] или приведением к массиву.
-
Завести реестр доработок, если его нет. Даже пустой файл лучше, чем знание, живущее в голове у одного человека.
-
Следующее обновление накатить сначала на копию и пройти по реестру руками.
Разница между «у нас коробка, мы боимся обновляться» и «у нас коробка, обновляемся по расписанию» — это не размер команды и не бюджет. Это выбор точки, в которую вы вклиниваетесь в чужую платформу, сделанный один раз и осознанно.

Когда доработка должна пережить следующее обновление, важно не только решить текущую задачу, но и выбрать устойчивую точку расширения.
Продолжить разбор можно на бесплатных открытых уроках:
-
3 августа, 20:00. «Кастомизация компонентов в Битрикс24». Записаться.
разберём способы доработки компонентов на примере графика отсутствий и связанных с ним служебных компонентов. -
19 августа, 20:00. «Модуль CRM глазами разработчика: практическое руководство». Записаться.
рассмотрим архитектуру CRM в Битрикс24 и возможности её адаптации под задачи бизнеса.
Автор: sproshchaev

