Правила PHPStan для проектов на 1С-Битрикс: устаревшее API старого ядра, выборки без кэша, N+1 по свойствам инфоблоков, SQL со склейкой строк, тяжёлые обработчики событий.
Общий PHPStan ловит ошибки типов, но про платформу не знает ничего: для него CIBlockElement::GetList() внутри foreach — обычный статический вызов, а не то, что кладёт базу на списке из тысячи элементов. Расширения под Битрикс на packagist нет; встроенный «Монитор качества» решает другую задачу — taint-анализ на XSS и инъекции.
| идентификатор | что находит |
|---|---|
bitrix.legacyApi.iblock |
вызов метода старого ядра инфоблоков, у которого есть D7-замена |
bitrix.legacyApi.catalog |
то же для торгового каталога |
bitrix.legacyApi.main |
то же для главного модуля: пользователи, группы, сайты, файлы, почта |
bitrix.legacyApi.sale |
то же для заказов и корзины |
bitrix.legacyApi.loader |
CModule::IncludeModule() вместо \Bitrix\Main\Loader::includeModule() |
bitrix.queryInLoop |
запрос к базе внутри цикла |
bitrix.propertyFetchInLoop |
получение свойств элемента на каждой итерации |
bitrix.getListWithoutSelect |
выборка без ограничения полей |
bitrix.getListWithoutIblockFilter |
выборка элементов без IBLOCK_ID в фильтре |
bitrix.getListWithoutCache |
выборка без кэша в публичной части |
bitrix.rawSqlConcatenation |
SQL, собранный конкатенацией или интерполяцией |
bitrix.unserializeFromRequest |
unserialize() данных запроса |
bitrix.queryInComponentEpilog |
запрос к базе в component_epilog.php |
bitrix.blockingCallInEventHandler |
синхронный сетевой вызов в обработчике события |
bitrix.eventHandlerRecursion |
обработчик, который повторно вызывает своё же событие |
bitrix.eventHandlerMissingEntityCheck |
обработчик события инфоблока без проверки IBLOCK_ID |
Выборка без IBLOCK_ID идёт по всем инфоблокам сразу, и заметно это обычно не по скорости, а по результату: символьный код уникален только внутри инфоблока, поэтому '=CODE' => $code может вернуть чужой элемент. Такой вызов нашёлся в компонентах самого ядра. По первичным ключам — ID, XML_ID, EXTERNAL_ID — правило молчит: они уникальны глобально.
Замена в сообщении называется точно. Для элементов инфоблока их две: \Bitrix\Iblock\ElementTable отдаёт только поля элемента, свойства выбираются через сгенерированный \Bitrix\Iblock\Elements\Element{ApiCode}Table.
Правила по обработчикам знают события инфоблоков, CRM, пользователей и заказов. Последние две группы добавлены по следам боевого проекта: там обработчики висели на OnAfterUserRegister и OnAfterUserLogin, а реестр знал только CRM и инфоблоки — то есть на обычном сайте, где нет ни сделок, ни каталога, все три правила молчали всегда.
Старое ядро разбито на пять идентификаторов по модулям не для красоты: на боевом проекте из 201 находки 95 пришлись на CModule::IncludeModule() — механическую замену, которая правится поиском с заменой. Под одним идентификатором пришлось бы выбирать между этими 95 срабатываниями и правилом целиком, а так шум глушится строкой identifier: bitrix.legacyApi.loader, и остаются 125 находок про архитектуру.
composer require --dev zavet-g/phpstan-bitrix
С phpstan/extension-installer правила подключаются сами; без него extension.neon пакета дописывается в includes вручную.
Дальше — команда, которая осматривает проект и печатает готовый phpstan.neon. Ничего не меняет, только читает:
vendor/bin/phpstan-bitrix-setup
code: 1068 php file(s) outside the kernel
vendored: 3 libraries copied into the project rather than required: ajax/inc/phpmailer, …
core: vendor/sheerockoff/bitrix-ci/files/bitrix/modules
orm hints: none. Run 'php bitrix/modules/main/cli.php orm:annotate' to generate them.
short tags: 248 file(s) open with <?, short_open_tag is OFF
Конфигурация из вывода вставляется как есть: пути собраны по тому, что лежит на диске, а не по соглашению об именах. Разница не косметическая — на боевом проекте, где код разложен по bitrix/templates и сорока каталогам публичной части, конфигурация «проанализировать local» покрывает 49 файлов из 1072, и прогон возвращается почти пустым.
Уровень предлагается нулевой: начиная с первого PHPStan сообщает о неопределённых переменных, а на Битриксе их в шаблонах сотни по устройству платформы. Поднимается после того, как первый прогон стал чистым.
Четыре вещи решают, увидит анализатор код или нет. Каждая проверяется командой выше, до того как отчёт соврёт.
Ядро. В репозитории его обычно нет, а без него неизвестны ни CIBlockElement, ни GetMessage(), ни $APPLICATION: на боевом проекте это 114 сообщений из 148. Ставится зависимостью — composer require --dev sheerockoff/bitrix-ci, — после чего detect-setup сам подставляет его в scanDirectories, и остаётся 34. Ядро, лежащее в самом проекте, попадает не туда, а в excludePaths.analyse: типы из него нужны, сообщения о нём — нет.
Тонкость, на которой теряется вся польза: vendor с ядром внутри нельзя исключать через analyseAndScan. scanDirectories не достаёт до пути, выброшенного excludePaths, и ядро снова становится невидимым — 1870 сообщений против 465 при одних и тех же правилах.
Переменные шаблона. Шаблон подключается изнутри CBitrixComponentTemplate::__IncludePHPTemplate(), который объявляет global $APPLICATION, $USER, $DB, присваивает $templateName, $templateFolder, $componentPath, $component, $templateData и только потом делает include — поэтому в теле есть и $this; $arResult с $arParams приходят по ссылке из CBitrixComponent. PHPStan читает шаблон как отдельный скрипт и всё это считает неопределённым: 133 сообщения на каталоге в 49 файлов. Так выглядит первый запуск на любом проекте Битрикса, и поэтому PHPStan на нём обычно не приживается.
includes:
- vendor/zavet-g/phpstan-bitrix/component-templates.neonИмена взяты из исходников ядра, сверяются поштучно и только под templates/ и components/, так что опечатка в шаблоне остаётся ошибкой. Это отдельный файл, а не часть расширения: молча глушить чужие правила пакет не должен.
Короткие теги. Файл, начинающийся с <?, PHPStan разбирает как HTML. В лучшем случае он просто выпадает из анализа, а прогон выглядит чистым. В худшем — и на боевом проекте вышло именно так — разбор ломается: <?if(...):?> съедается как текст, а endif остаётся, и появляется синтаксическая ошибка. Она неигнорируема, после неё PHPStan объявляет результат неполным и останавливается. Четырёх таких файлов хватило, чтобы из 1068 не проверился ни один; с включённой настройкой — 339 сообщений, 220 от этих правил.
Настройка читается из php.ini, а не из аргументов: php -d short_open_tag=1 не работает, параллельные процессы её не наследуют.
mkdir -p .phpstan-php
echo short_open_tag=On > .phpstan-php/short-tags.ini
PHP_INI_SCAN_DIR=":$PWD/.phpstan-php" vendor/bin/phpstan analyse
Двоеточие в начале обязательно: без него PHP перестаёт читать свой обычный каталог .ini, где в образах вроде php:8.3-cli лежат подключения расширений. Правило bitrix.unparsedShortTagFiles сообщает о таких файлах само, но только если разбор дошёл до конца — прерванный прогон до сбора данных не доходит.
Константы. SITE_ID, LANGUAGE_ID, BX_ROOT и полтора десятка других определяет пролог, в коде их объявления нет. Пакет объявляет их стабом и помечает изменяемыми, чтобы литерал из стаба не принимался за настоящее значение и условия по SITE_ID не сворачивались.
Три места, где ядро отвечает анализатору неправильно, и пакет отвечает за него.
CIBlockElement::GetList() объявлен как integer|CIBlockResult, потому что при агрегации отдаёт число, — из-за этого $res->Fetch() в обычном цикле становится ошибкой. Число приходит только когда третьим аргументом передана группировка, и это видно из кода: тип сужается до CIBlockResult, когда группировки нет. CCatalogProduct::GetList() и CPrice::GetList() объявлены как bool|CDBResult и не сужаются — false там означает ошибку выполнения, и проверять его нужно.
Application::getConnection() объявлен как Data\Connection|DB\Connection, а getSqlHelper() есть только у второго. На объединении вызов становится ошибкой, forSql() вырождается в mixed, и правило о сыром SQL теряет единственный признак, по которому отличает экранированное значение от сырого, — раньше это давало 18 ложных срабатываний из 28. Тип сужается стабом, и экранирование распознаётся в том числе через промежуточную переменную.
orm:annotate генерирует геттеры с типами вида @method \string getName() — так их пишет само ядро, ScalarField::getGetterTypeHint() возвращает '\string'. Для PHPStan это класс с именем string, и каждый такой метод даёт три ошибки; на проекте с десятком сущностей их сотни. Пакет разбирает такие типы как скаляры.
Отдельно LocalRedirect() и WriteFinalMessage() объявлены как never: сами по себе они не содержат exit, а вызывают Application::end(), где есть ветка return, поэтому на девятом и десятом уровне PHPStan считает код после редиректа достижимым. Стаб переопределяет PHPDoc, но не объявляет символ, так что рядом нужен scanFiles: bitrix/modules/main/tools.php — функции ядра лежат там, а scanDirectories подхватывает только классы.
Все правила включены. Выключаются разом или по одному:
parameters:
bitrixRules:
allRules: false
queryInLoop: trueПравило про кэш срабатывает только под путями публичной части — агентам и консольным скриптам кэш не нужен. Список того, что считается блокирующим вызовом, тоже открыт: свой HTTP-клиент дописывается туда.
parameters:
bitrixPublicPathFragments:
- /local/templates/
bitrixBlockingMethods:
- App\Http\ApiClient::sendОтдельная ошибка глушится по идентификатору, без отключения правила: ignoreErrors: [{identifier: bitrix.getListWithoutSelect, path: local/modules/legacy/*}].
Первый запуск на живом коде даст сотни ошибок — это нормально. Текущее состояние фиксируется как нулевая отметка, и правила начинают ловить новый код:
vendor/bin/phpstan analyse --generate-baseline
Часть ошибок baseline не принимает: несовместимые сигнатуры при наследовании PHPStan считает неигнорируемыми. Почти всегда они приходят из библиотеки, скопированной в проект руками — старого SDK платёжного шлюза, PHPMailer в каталоге ajax-обработчиков, — и пока такой каталог в анализе, чистого прогона не будет. detect-setup находит их по собственному composer.json и выносит в excludePaths.analyse; библиотеку без composer.json придётся дописать самому, отличить её от кода проекта не по чему.
Откладывать подключение ядра не стоит: без него правило про свойства элемента шумит сильнее, а не слабее — тип не выводится, отсеять чужой объект не по чему, и $report->getProperty() в цикле попадёт под правило наравне с настоящим _CIBElement.
Правила не разбирают файлы сами — работают с деревом, которое PHPStan уже построил. Логика распознавания вынесена в детекторы и переиспользуется: «этот вызов идёт в базу», «этот узел лежит внутри цикла». Соответствия старого API и D7 лежат данными, а не ветками if, и каждая строка сверена с исходниками ядра.
Три правила по обработчикам работают между файлами: регистрация в одном, тело в другом, внутри одного дерева их не связать. Два коллектора собирают регистрации и тела-кандидаты, PHPStan сводит данные всех процессов в CollectedDataNode, правило соединяет их по паре «класс — метод». Кандидатами считаются только публичные статические методы и функции — callable вида ['Class', 'method'] до метода объекта не достаёт.
Обход тела цикла останавливается на вложенных циклах, замыканиях и телах методов: запрос во внутреннем цикле сообщается один раз, а объявленный внутри цикла код не обязан выполняться на каждой итерации.
Тесты на своих фикстурах замкнуты: фикстура пишется под правило. Поэтому правила прогнаны на коде, написанном без оглядки на них.
| корпус | объём | находок |
|---|---|---|
| компоненты и шаблоны ядра | 17 006 файлов | 733 |
| семь открытых репозиториев с раскладкой проекта | 3 294 файла | 92 |
| семь проектов интернет-магазинов | 12 225 файлов | 2 236 |
| боевой проект с продакшена | 1 273 файла | 220 |
Первый прогон по ядру дал 793 срабатывания, из них 60 ложных, и все они исправлены с тестом на каждое: позиция select-аргумента была взята у CIBlockElement::GetList и распространена на методы, где такого аргумента нет вовсе; правило о сыром SQL срабатывало на 'WHERE ID = ' . (int) $id, хотя приведение к числу инъекцию не переносит. Корпус магазинов добавил ещё один вид: цикл по array_chunk() — это не N+1, а его исправление. Открытый код пишут аккуратнее боевого, плотность находок на нём втрое ниже.
Межфайловая связка проверена на чужом коде: обработчик, зарегистрированный через EventManager строковым именем класса, с телом в третьем файле. Правило молчит на правильном обработчике и указывает на строку тела, если убрать оттуда сравнение IBLOCK_ID.
Отдельно пройден весь путь пользователя на копии боевого проекта — от composer require до внесённого дефекта. Правила оказались в порядке, а пакет вокруг них нет: первый отчёт разбирал 49 файлов из 1072, и на находки правил в нём приходилось одно сообщение из 148. Из этого выросло всё, что описано в разделе про первый запуск. Итог на том же проекте — 220 находок из 339 сообщений, после baseline прогон чистый, а добавленный файл с N+1, конкатенацией в SQL и unserialize() от запроса даёт все три находки.
На корпусе ядра всплыло, что 56 файлов из 17 006 не разбираются ни PHPStan, ни самим PHP — незакрытые скобки. Такие файлы придётся заносить в excludePaths: на синтаксической ошибке анализ останавливается целиком.
Замер: 17 006 файлов за 24.6 секунды на PHP 8.5, Apple M4, пустой кэш. В CI на каждый пуш гоняется composer bench — 2000 сгенерированных файлов, около 370 файлов в секунду, порог 90 секунд. Корпус синтетический намеренно: сборка не должна зависеть от стороннего репозитория.
Проводка расширения проверяется отдельно, composer check-wiring: юнит-тесты создают правила руками и сломанного conditionalTags или неверного пути к стабу не заметят. Битрикс для тестов не нужен, зависимости от ядра у пакета нет — она сделала бы установку тяжелее самого анализатора.
Правило, которое не уверено, молчит. Ложное срабатывание стоит дороже пропуска: одно выключает доверие ко всему пакету, второе оставляет его работать. Отсюда четыре группы того, где находок не будет.
Нужен видимый тип. Правило про свойства элемента и распознавание экранирования в SQL опираются на вывод типов, то есть на подключённое ядро. Без него первое шумит, а второе перестаёт отличать forSql() от сырой строки. Экранирование чужим хелпером, не наследующим SqlHelper, не распознаётся вовсе.
Не выводится из синтаксиса. Параметры выборки, собранные в переменную, имя события в переменной, замыкание вместо callable, $entity->getDataClass()::getList() у Highload-блоков, API_CODE инфоблока для имени Element{ApiCode}Table — всё это в дереве отсутствует. Сюда же SQL через sprintf(), где %d и %s неразличимы без анализа значений, и N+1 внутри метода, тело которого анализатору не видно.
Различимо только в рантайме. GetProperties() может отдать уже загруженные свойства без запроса; версия инфоблока, от которой зависит число джойнов, хранится в базе; проверка IBLOCK_ID через константу-ключ или в отдельном методе выглядит как её отсутствие. Правило про обработчики сообщает об отсутствии проверки, поэтому ошибается в сторону молчания.
Опознание по имени. Старое ядро узнаётся по имени класса в глобальном пространстве: собственный CUser в своём пространстве имён правило не тронет, одноимённый в глобальном — не отличит. Классы на Table считаются ORM-сущностями; собственный ReportTable глушится по идентификатору.
Отдельно: межфайловые правила дают полный результат только на полном прогоне — при phpstan analyse путь/к/файлу.php регистрация из init.php в анализ не попадёт. И цикл, выполняющийся один раз, пропускается, только если это видно синтаксически: безусловный break, foreach по массиву из одного элемента, do-while(false); выход через условный continue требует анализа потока управления, и правило сообщит.
Анализатор не заменяет ревью: он находит то, что описано правилами.
С 01.02.2026 Битрикс не ставит обновления на продукты с PHP ниже 8.2 — это нижняя граница живых проектов, а требовать больше значит отрезать тех, кому анализатор и нужен. Сама 8.2 в режиме исправлений безопасности до 31.12.2026; после этого граница поднимется до 8.3 мажорным релизом. По той же причине в коде нет property hooks и асимметричной видимости — они появились в 8.4.