
Пятница, 18:00. Вы обновляете PHP на сервере с 7.4 на 8.0 — что может пойти не так? Деплой проходит, страница обновляется, и вместо привычного портала вы видите белый экран с TypeError: array_search(): Argument #2 ($haystack) must be of type array, null given. Мессенджер разрывается от сообщений: «Ничего не работает». Знакомо?
Привет! Меня зовут Дмитрий Черкашин, я разработчик в Битрикс24. Проанализировав девчаты разработчиков и опираясь на личный опыт, я собрал самые распространённые ошибки в кастомизациях коробочной Битрикс24 в две главы:
Первая — про ловушки ORM, в которые попадаешь, когда пытаешься написать код «по-современному».
Вторая — про боли миграции. Это о том, как в PHP 8+ безжалостно выстреливает легаси-код, который годами работал «на честном слове».
Внутри — конкретные ошибки с кодом, цифры производительности и интерактивные задачки. Проверьте, сколько из этих грабель вы уже собрали.
1 — Ловушки ORM: производительность, память и сложные запросы
ORM был создан как более современный и безопасный способ работы с базой данных в Битрикс24. В отличие от старого ядра, где работа велась в процедурном стиле через статические методы вроде CIBlockElement::GetList(), новое ядро основано на объектно-ориентированном подходе. Такой подход делает код более читаемым и удобным для поддержки.
Хотя ORM помогает писать более структурированный и поддерживаемый код, легко споткнуться о некоторые особенности поведения. Даже опытные разработчики сталкиваются с ситуациями, когда «красивый» код на ORM внезапно падает с исключением или возвращает неверные данные.
Общая рекомендация: Используйте
::query()вместоGetListи старого стиля ORM. Новый API позволяет избежать неявных UPPER и LIKE, которые ломают индексы БД.
Как ORM строит запросы
1.1. ORM больше не разрешает использовать SQL-выражения прямо в select
Современное ядро ужесточило требования к безопасности запросов. В старых версиях Битрикс24 разработчики часто смешивали ORM и фрагменты SQL. Например, можно было прямо внутри массива select написать SUM(PRICE) или COUNT(*), и ORM спокойно подставлял это в итоговый SQL-запрос.
В новых версиях ядра это считается небезопасным. Попытка прокинуть массив с выражениями напрямую в секцию select метода getList теперь приводит к фатальному исключению Bitrix\Main\ArgumentException. Система выдает ошибку: «Expression as an array in select section is no more supported due to security reason».
Как делать не надо:
$result = SomeTable::getList([ 'select' => [ 'ID', 'TOTAL' => 'SUM(AMOUNT)', // sql выражение больше нельзя! ], 'group' => ['DEAL_ID'], ]);
Как надо: для вычисляемых полей необходимо использовать объекты ExpressionField, метод Query->registerRuntimeField или передавать готовый объект поля вместо массива.
use Bitrix\Main\Entity\ExpressionField; $query = SomeTable::query() ->registerRuntimeField( new ExpressionField('TOTAL', 'SUM(%s)', ['AMOUNT']) ) ->setSelect(['DEAL_ID', 'TOTAL']) ->addGroup('DEAL_ID') ; foreach ($query->exec() as $row) { }
1.2. Почему сложные JOIN в ORM часто ломаются: ошибка "Unknown reference value"
Простые ORM-запросы обычно работают без проблем: выбрать список сделок, получить пользователей или найти элементы инфоблока. Но при работе с несколькими таблицами появляются запутанные ошибки ORM, который требует строгого описания связей.
Простая подстановка значений в ReferenceField больше не работает. При попытке построить сложные связи между таблицами (например, джойн свойств инфоблока) разработчики часто получают ошибку «Unknown reference value».
Пример: когда в описании связи Reference пытаются использовать числовые значения напрямую (например, ID свойства), ORM не понимает, как это интерпретировать в контексте маппинга полей.
Решение: необходимо использовать Runtime поля и корректно описывать условия связи через Join::on. Советую заглядывать в метод getQuery(), чтобы увидеть реальный SQL-запрос, который строит ORM, и понять, где именно отваливается логика джойна.
Как делать не надо:
use Bitrix\Main\ORM\Query\Join; use Bitrix\Main\Entity\ReferenceField; // числовое значение напрямую вызовет "Unknown reference value" $query = \Bitrix\Iblock\ElementTable::query(); $query->registerRuntimeField( new ReferenceField( 'PROP_BRAND', \Bitrix\Iblock\ElementPropertyTable::class, [ '=this.ID' => 'ref.IBLOCK_ELEMENT_ID', 'ref.IBLOCK_PROPERTY_ID' => 42, // ОШИБКА! ] ), );
Как надо:
use Bitrix\Main\ORM\Query\Join; use Bitrix\Main\Entity\ReferenceField; use Bitrix\Main\DB\SqlExpression; // используем SqlExpression для фиксированных значений $query = \Bitrix\Iblock\ElementTable::query(); $query->registerRuntimeField( new ReferenceField( 'PROP_BRAND', \Bitrix\Iblock\ElementPropertyTable::class, Join::on('this.ID', 'ref.IBLOCK_ELEMENT_ID') ->where('ref.IBLOCK_PROPERTY_ID', new SqlExpression('?i', 42)) ) );
1.3. Почему delete($id) работает не для всех таблиц
Во многих ORM-системах любую запись можно удалить через простой вызов delete($id). Это работает, когда у таблицы есть обычный первичный ключ — например, поле ID.
Некоторые системные таблицы Битрикс24 используют составной первичный ключ вместо обычного ID. Из-за этого стандартное удаление через delete($id) перестаёт работать, и требуется передавать весь составной ключ целиком.
Если нужно удалить набор строк не по primary key, а по условию, используйте deleteByFilter(). Но с ним нужно быть осторожным: фильтр должен быть максимально точным, иначе можно удалить больше данных, чем планировалось. Кроме того, перед использованием нужно подключить трейт Bitrix\Main\ORM\Data\Internal\DeleteByFilterTrait.
Пример: таблица прав \Bitrix\Main\TaskOperationTable использует составной первичный ключ (TASK_ID + OPERATION_ID).
Как бороться: в таких случаях метод удаления ожидает не просто числовой ID, а массив, полностью идентифицирующий конкретную строку:
// стандартное удаление - работает для таблиц с одиночным PK SomeTable::delete($id); // $id = 123 // составной первичный ключ - передаём массив \Bitrix\Main\TaskOperationTable::delete([ 'TASK_ID' => 456, 'OPERATION_ID' => 7, ]); // удаление по условию SomeTable::deleteByFilter([ '=ENTITY_ID' => $entityId, ]);
ORM — мощный инструмент, но он требует понимания внутренних механизмов.
Почему ORM начинает тормозить и перегружать память
1.4. Опасность SELECT * и полей типа TEXT
При использовании ORM легко забыть указать конкретные поля в выборке — и получить серьёзную деградацию. Если не указать select, запрос начинает вытягивать все поля таблицы. Особенно опасно это для полей TEXT и BLOB, которые резко увеличивают потребление памяти и время выполнения запросов.
На небольших проектах это может быть незаметно. Но в CRM Битрикс24 таблицы часто содержат много служебных и тяжёлых полей: описания, JSON-структуры, комментарии, служебные данные. В результате даже простой запрос может создавать серьёзную нагрузку и замедлять страницу.
Визуально код выглядит нормально, потому что разработчик просто забыл указать select. Но внутри ORM это фактически аналог SELECT *.
В чём проблема: поля типа TEXT и BLOB увеличивают объём данных, который база должна прочитать и передать в PHP. Затем эти данные попадают в память процесса и могут увеличивать размер кеша.
Тестовые замеры: в тестах выборка логина и имени заняла 0.04с, а всех полей — 0.83с с потреблением 498 МБ памяти.
Как делать не надо:
// тянем все поля, включая TEXT/BLOB $users = \Bitrix\Main\UserTable::getList([ 'filter' => ['ACTIVE' => 'Y'], ])->fetchAll(); // нет select → ORM выберет ВСЕ поля → 0.83с, 498 МБ
Как надо:
// только нужные поля $result = \Bitrix\Main\UserTable::query() ->setSelect(['ID', 'LOGIN', 'NAME', 'LAST_NAME']) ->where('ACTIVE', 'Y') ->exec() ; while ($user = $result->fetch()) { // работа с $user } // 0.04с, минимум памяти
Конкретные цифры зависят от таблицы, объёма данных и окружения, но порядок разницы хорошо показывает цену SELECT *.
Всегда указывайте конкретные поля в select. Никогда не полагайтесь на «выберется всё, что нужно».
1.5. Как fetchAll() может положить сервер по памяти
После выполнения ORM-запроса данные нужно обработать в PHP.
Многие разработчики по привычке используют fetchAll(), который загружает весь результат в память сразу, что при больших объёмах мгновенно приводит к Fatal error: Allowed memory size exhausted.
Проблема в том, что fetchAll() загружает в память абсолютно все строки выборки одновременно. Если таблица маленькая — всё нормально. Но в CRM, логах, задачах или инфоблоках количество строк может исчисляться десятками и сотнями тысяч.
Первый шаг — обрабатывать результат через fetch(), не складывая всё в массив. Но если выборка большая, одного fetch() мало: данные лучше читать батчами, например по 500-1000 строк, с явной сортировкой по ID.
Как делать не надо:
// загружает ВСЕ строки в массив в RAM $rows = SomeTable::getList([ 'select' => ['ID', 'NAME', 'PRICE'], 'filter' => ['ACTIVE' => 'Y'], ])->fetchAll(); // 100 000 строк приведет к memory_limit exceeded
Как надо:
// потоковая обработка, по одной строке $result = SomeTable::query() ->setSelect(['ID', 'NAME', 'PRICE']) ->where('ACTIVE', 'Y') ->exec() ; while ($row = $result->fetch()) { processRow($row); // каждая итерация - минимальное потребление памяти } // для больших выборок: читаем данные батчами $lastId = 0; $batchSize = 1000; do { $result = SomeTable::query() ->setSelect(['ID', 'NAME', 'PRICE']) ->where('ACTIVE', 'Y') ->where('ID', '>', $lastId) ->setOrder(['ID' => 'ASC']) ->setLimit($batchSize) ->exec() ; $count = 0; while ($row = $result->fetch()) { $lastId = (int)$row['ID']; $count++; processRow($row); } } while ($count === $batchSize);
1.6. Кешируйте ORM-запрос, а не коллекцию
После перехода на коллекции многие пытаются положить ORM-объекты в кеш и сталкиваются с ошибками сериализации. Например, кешировать результат выборки ORM (объект EO_Collection) через стандартный механизм endDataCache(). Это сразу вызывает ошибку Serialization of 'Closure' is not allowed.
Почему так: объекты ORM (Entity Object и Collections) хранят ссылки на Entity-метаданные, которые содержат замыкания (Closure) — валидаторы полей, дефолтные значения, колбэки. PHP не умеет сериализовать Closure.
Что делать: используйте кеширование самого запроса, а не результирующей коллекции.
Как делать не надо:
use Bitrix\Main\Data\Cache; if ($cache->initCache($cacheTime, $cacheId, $cacheDir)) { // достаём из кеша уже обычный массив $items = $cache->getVars()['items']; } elseif ($cache->startDataCache()) { $collection = \Bitrix\Crm\DealTable::getList([ 'select' => ['ID', 'TITLE', 'OPPORTUNITY'], 'filter' => $filter, ])->fetchCollection(); // упадёт с "Serialization of 'Closure' is not allowed" $cache->endDataCache(['items' => $collection]); }
Как надо:
$collection = \Bitrix\Crm\DealTable::query() ->setSelect(['ID', 'TITLE', 'OPPORTUNITY']) ->setFilter($filter) ->setCacheTtl($cacheTtl) ->exec() ->fetchCollection() ;
2. Боли миграции на новую версию PHP
Переход на PHP 8.0 и выше стал для многих «холодным душем». В 2023 году минимальной требуемой версией PHP для работы Битрикс24 стала 8.0, поэтому многим разработчикам пришлось мигрировать свои кастомные наработки. В PHP 8.x язык стал строже относиться к типам, сигнатурам методов и некорректным вызовам.
Если PHP 7.4 прощал небрежность в типах данных, то новая версия превратила старые предупреждения в фатальные ошибки, роняющие порталы.
Особенно болезненно это проявилось в старых кастомизациях Битрикс24, которые пишут наши партнёры для дополнительных возможностей в своих бизнес-порталах. Во многих шаблонах, компонентах и модулях данные годами использовались без проверок: массив оказывался null, метод вызывался не тем способом, а сигнатуры интерфейсов не совпадали полностью. Раньше это либо игнорировалось, либо вызывало предупреждение. Теперь такие места роняют страницы, AJAX-запросы и фоновые процессы.
2.1. Фатальные ошибки типизации и «хрупкие» стандарты
Одна из проблем после перехода на PHP 8 связана с тем, что язык стал гораздо строже проверять типы данных. В PHP 7 многие встроенные функции спокойно принимали неправильные значения, в PHP 8 — нет.
Самая частая «боль» — это использование функций array_search(), usort(), in_array() и count() с переменными, которые неожиданно оказываются null.
// было (PHP 7.4 прощал Warning) $key = array_search('active', $arParams['STATUS_LIST']); // стало (PHP 8.0+ фатальный TypeError) // TypeError: array_search(): Argument #2 ($haystack) must be of type array //null given
Ловушка array-функций: начиная с PHP 8.0 все функции семейства array_* (array_search, usort, in_array, array_keys и др.) стали бросать TypeError при получении null вместо массива. Раньше это оставалось предупреждением E_WARNING, теперь — фатальная ошибка. Особенно критично там, где данные приходят неструктурированными, часто незаполненными, и null — не исключение, а норма.
Лайфхак: используйте
empty()вместоcount() > 0. Это не просто защита отTypeError, это ещё и микрооптимизация.
empty() — языковая конструкция (как isset), а не вызов функции. Она не создаёт стекового фрейма, не выполняет lookup в таблице функций и не бросает TypeError на null. В высоконагруженных шаблонах, которые рендерятся тысячи раз в день, эта разница складывается.
Строгий array_search(): Аналогично, попытка найти значение в массиве, который пришел как null (например, из параметров компонента или неверно полученного свойства), теперь считается критической ошибкой.
// типичная ошибка в шаблоне компонента $arParams не гарантирует массив // PHP 7.4: вернет false без ошибки // PHP 8+: TypeError $key = array_search('active', $arParams['STATUS_LIST']); // защитный вариант $statusList = $arParams['STATUS_LIST'] ?? []; $key = is_array($statusList) ? array_search('active', $statusList) : false;
2.2. Старые классы и библиотеки перестают соответствовать интерфейсам PHP 8
После перехода на PHP 8 в старом коде начали всплывать ошибки несовпадения сигнатур методов. Особенно это касается кастомных библиотек, модулей и классов, написанных ещё под старые версии PHP, а также сторонних модулей, использующих устаревшие реализации интерфейсов PSR.
Раньше язык довольно спокойно относился к расхождениям между сигнатурой метода и интерфейсом: метод мог принимать аргументы без указания типа, хотя интерфейс ожидал int, или возвращать значение без указанного return type.
В PHP 8 сигнатуры методов должны совпадать практически полностью: типы аргументов, возвращаемые значения и иногда даже nullable-типизация. Поэтому старые библиотеки и кастомные модули начинают выдавать фатальные ошибки сразу после обновления.
Как делать не надо:
// старая сигнатура метода без типов class MyIterator implements \SeekableIterator { public function seek($offset) { /* ... */ } // PHP 8+: return type }
Как надо:
// строгая сигнатура, соответствующая интерфейсу class MyIterator implements \SeekableIterator { public function seek(int $offset): void { /* ... */ } }
2.3. Нестатические методы, вызываемые как статические
В старом PHP многие ошибки объектной модели игнорировались. Например, разработчик мог случайно вызвать обычный метод класса как статический через ClassName::method(), и PHP либо показывал warning, либо продолжал выполнять код.
Метод vs Статика: PHP 8.0 перестал игнорировать вызов динамических методов как статических. Ошибки вроде Cannot make non static method ... static стали обыденностью при обновлении ядра или использовании легаси-кода.
Как делать не надо:
// PHP 7.4: Deprecated-предупреждение (легко пропустить) // PHP 8+: Fatal error CMyLegacyModule::doSomething(); // метод doSomething() не static
Как надо:
// сделать метод статическим (если логика позволяет) public static function doSomething(): void { /* ... */ }
2.4. Стратегия «защитного» программирования
После перехода на PHP 8 видно, что проблема не только в отдельных ошибках, а в самом стиле старого кода. Во многих кастомизациях разработчики привыкли полагаться на то, что данные скорее всего придут правильно: массивы существуют, свойства заполнены, а методы вызываются корректно.
Новые версии языка требуют более аккуратного и предсказуемого подхода. Новый PHP подталкивает разработчиков к более строгому и безопасному стилю программирования.
Чтобы ваши кастомизации не превратились в тыкву после обновления версии PHP на сервере, следуйте правилам.
Внедрите строгие проверки is_array(). Забудьте про краткую запись if (count($val)). Перед любой операцией с массивом (особенно если это свойства инфоблока или параметры компонента) всегда проверяйте тип данных. Ещё лучше — используйте empty() вместо count() > 0.
Как делать не надо:
// сделать метод статическим (если логика позволяет) public static function doSomething(): void { /* ... */ }
Как надо:
// хорошо явная проверка перед каждой операцией $items = $arItem['ITEMS'] ?? []; if (is_array($items) && !empty($items)) { $found = array_search($itemId, $items); }
Проверяйте сигнатуры при наследовании. Если вы переопределяете методы системных таблиц (например, getMap()), убедитесь, что возвращаемые типы и аргументы в точности соответствуют родительскому классу в ядре.
Как делать не надо:
// раньше работало class MyEntityTable extends \Bitrix\Main\Entity\DataManager { public static function getMap() { return [ /* ... */ ]; } }
Как надо:
// проверяем сигнатуру в родителе и копируем точно class MyEntityTable extends \Bitrix\Main\Entity\DataManager { public static function getMap(): array { return [ /* ... */ ]; } }
Включите диагностику через логи. При миграции первым делом включайте display_errors и логирование в .settings.php, потому что многие ошибки в PHP 8 «молчат» в публичной части, но мгновенно убивают AJAX-запросы и фоновые процессы.
Подробнее про настройку обработки ошибок и логирования можно посмотреть в документации Bitrix Framework.
Принудительно приводите к массиву. Для устранения фатальных ошибок count(null) в PHP 8 рекомендуется массово заменять переменные на (array)$variable в проблемных строках.
// быстрый «патч» для массовой миграции шаблонов // вместо рефакторинга каждого if/foreach приводим к массиву в точке входа $items = (array)($arResult['ITEMS'] ?? []); foreach ($items as $item) { $props = (array)($item['PROPERTIES'] ?? []); // теперь count() и foreach гарантированно не упадут }
Исправление сессий: При возникновении ошибки session_start до инициализации ядра (часто после перехода на PHP 8) помогает проверка файлов dbconn.php и init.php на наличие лишних пробелов или текста после закрывающего тега ?>.
Интерактив: найдите все места, где упадёт PHP 8+
Прежде чем двигаться дальше — проверьте себя. Вот реальный фрагмент из шаблона компонента. Сколько потенциальных TypeError вы здесь видите?
$items = $arResult['ITEMS']; $count = count($items); foreach ($items as $item) { $photos = $item['PROPERTIES']['PHOTOS']['VALUE']; $photoCount = count($photos); $tags = $item['PROPERTIES']['TAGS']['VALUE']; $hasDiscount = array_search('discount', $tags) !== false; $related = $item['RELATED_ITEMS']; usort($related, function ($a, $b) { return $a['SORT'] <=> $b['SORT']; }); echo "Товар: {$item['NAME']}, фото: {$photoCount}"; }
Ответ
Минимум 5 проблемных мест:
Строка 2:
count($items)— если$arResult['ITEMS']не задан,$items = null→TypeError(фатал!)Строка 4:
foreach($items as $item)— если$arResult['ITEMS']не задан,$items = null→TypeError(фатал!)Строка 6:
count($photos)—TypeError(фатал!)Строка 10:
array_search('discount', $tags)—TAGSможет бытьnull→TypeError(фатал!)Строка 13:
usort($related, ...)—RELATED_ITEMSможет отсутствовать →TypeError(фатал!)
Исправленный вариант:
$items = (array)($arResult['ITEMS'] ?? []); foreach ($items as $item) { $photos = (array)($item['PROPERTIES']['PHOTOS']['VALUE'] ?? []); $tags = (array)($item['PROPERTIES']['TAGS']['VALUE'] ?? []); $related = (array)($item['RELATED_ITEMS'] ?? []); $hasDiscount = array_search('discount', $tags) !== false; if (!empty($related)) { usort($related, fn($a, $b) => ($a['SORT'] ?? 0) <=> ($b['SORT'] ?? 0)); } }
Переход на PHP 8 — это не просто смена версии, а переход к культуре строгой типизации.
Простое правило: каждый null должен быть обработан до того, как он попадёт в стандартную функцию. Если вы запомните только одну вещь из этой главы — пусть это будет $var ?? [] перед любым count, array_search и foreach.
Промпт для AI-проверки:
Скопируйте этот промпт в AI-ассистента и приложите к нему код шаблона, компонента, модуля или ORM-запроса, который хотите проверить.
# Промпт для AI-проверки кода ## Контекст Целевой стек: PHP 8+, Битрикс24 с модулем main версии 23.0.0+. Ключевые классы Bitrix ORM: - Таблицы наследуются от \Bitrix\Main\ORM\Data\DataManager - Query Builder: \Bitrix\Main\ORM\Query\Query (через ::query()) - Поля: \Bitrix\Main\ORM\Fields\{ScalarField, IntegerField, StringField, ExpressionField, Relations\Reference, Relations\OneToMany, Relations\ManyToMany} - JOIN-условия: \Bitrix\Main\ORM\Query\Join::on('this.FIELD', 'ref.FIELD') - Удаление по фильтру: трейт \Bitrix\Main\ORM\Data\Internal\DeleteByFilterTrait - Результаты: fetch(), fetchAll(), fetchObject(), fetchCollection() В шаблонах компонентов структура $arResult приходит извне и может содержать скаляры/null там, где ожидается массив — защитные приведения типов уместны. ## Что проверять 1. ORM-запросы - Запросы без явного select() → добавь выбор только нужных полей. - Сложные getList() → предложи ::query(). - SQL-выражения строкой или массивом в select → замени на ExpressionField. - JOIN/ReferenceField → проверь runtime-поля, Join::on(), безопасность SQL. - fetchAll() на больших выборках → fetch() в цикле или батчинг. - ORM-объекты/EO_Collection в обычном кеше → кешируй сам запрос используя setCacheTtl. 2. Удаление через ORM - delete($id) при составном PK → массив ключей. - Удаление по условию → deleteByFilter() + предупреждение о точности фильтра. 3. Совместимость с PHP 8.x - array-функции (count, array_search, in_array, array_keys, usort и т.д.) на возможном null/не-массиве → защита через is_array() / ?? [] / (array). - foreach по переменным, которые могут быть null. - Обращение к вложенным массивам без ?? / isset(). - Вызовы нестатических методов как статических. - Несовпадение сигнатур с интерфейсом или родителем (типы аргументов, return type, nullable). ## Чего НЕ делать - Не переписывай CIBlockElement::GetList на ORM без отдельной просьбы. - Не меняй стиль форматирования кода (Битрикс использует фигурные скобки на новой строке для классов и методов). - Не меняй бизнес-логику без необходимости. - Не выдумывай классы, поля таблиц и сигнатуры методов. Если не хватает getMap(), схемы таблицы или версии ядра — пометь место как требующее ручной проверки. ## Формат ответа 1. Список найденных проблем с пояснением, почему каждая опасна. 2. Сначала сделай аудит. Код исправляй только после списка проблем или если я явно прошу сразу исправленный вариант. 3. Если для исправления не хватает контекста — явно укажи, какого. 4. Отдельным блоком — места, требующие ручной проверки разработчика.
Желаю, чтобы ваши продакшены не падали, страницы грузились моментально, а TypeError встречался реже. Удачи в проектах!