PHPStan: уровни по слоям и strict-rules
Как разложить статический анализ по слоям приложения:
levelв PHPStan глобальный, поэтому «строгий домен и снисходительный legacy» делается не одним конфигом, а несколькими. Плюс разборphpstan-strict-rules— что включать везде, что только в домене, и что ломается на границах.
Уровни по слоям: несколько конфигов
level в PHPStan — глобальный параметр: один конфиг = один уровень для всех
путей внутри него. Разные требования к разным слоям выражаются набором
конфигов, а не одним файлом.
phpstan/base.neon # общие настройки, bootstrap, stubs
phpstan/domain.neon # level 10, paths: src/Domain, src/Application
phpstan/infra.neon # level 9, paths: src/Infrastructure, src/Presentation
phpstan/legacy.neon # level 6, paths: views, config, migrations
base.neon включается в каждый из трёх конфигов и держит всё, что не
зависит от слоя: bootstrapFiles, stubs, игноры сторонних библиотек,
общие правила.
В CI это три отдельные задачи — по одной на конфиг.
У каждого конфига должен быть свой
tmpDir. С общим кэшемзапуски с разными уровнями конфликтуют между собой, и результат анализа начинает зависеть от порядка запусков.
Смысл разделения: домен пишется с нуля и держит level 10 бесплатно; границы (адаптеры, гидраторы, контроллеры) живут с внешними данными и типами сторонних библиотек, поэтому уровень там ниже; legacy-код (шаблоны, конфиги, миграции) фиксируется на достижимом уровне — чтобы не блокировать CI, но и не деградировать дальше.
phpstan-strict-rules
Везде, во всех слоях → base.neon
Набор правил с практически нулевой ценой внедрения — их можно включить одним коммитом даже в шаблонах и конфигах:
disallowedLooseComparison—==/!=вон.strictFunctionCalls—in_array($x, $a)без третьего аргументаtrueэто классический источник багов.strictArrayFilter—array_filter()без коллбэка.disallowedBacktick,noVariableVariables,matchingInheritedMethodNames,requireParentConstructorCall,illegalConstructorMethodCall.overwriteVariablesWithLoop,dynamicCallOnStaticMethod.
Только домен → domain.neon, allRules: true
В src/Domain и src/Application включается весь набор целиком:
booleansInConditionsиbooleansInLoopConditions— главная ценность набора: запрещаетif ($order),if ($string),if ($count). В домене это бесплатно и сразу вычищает неявные приведения.numericOperandsInArithmeticOperators— в VO с типизированнымиint/floatне стоит ничего.checkAlwaysTrueInstanceof,checkAlwaysTrueCheckTypeFunctionCall,checkAlwaysTrueStrictComparison— вычищают мёртвые защитные проверки, которых в правильном домене быть не должно.disallowedEmpty,disallowedShortTernary,uselessCast.
На границах → infra.neon
Здесь набор включается выборочно, и каждое правило имеет свою цену:
booleansInConditions— самое частое место срабатывания:if ($request->getAttribute('user')),if ($row['deleted_at']). Заставляет писать явные проверки в адаптерах — это и есть цель.numericOperandsInArithmeticOperators— срабатывание здесь сигнал, что гидратор недоделан и тащит наружу строки из БД.checkAlwaysTrue*— обязательно вместе сtreatPhpDocTypesAsCertain: false, иначе защитные проверки против лгущих аннотаций сторонних библиотек будут падать как «always true». Это стандартная связка.uselessCast— периодически конфликтует с защитным(string)/(int)на границе; если конфликтует часто, на этом слое его проще выключить.
Правило разделения простое: чем ближе код к внешнему миру, тем
меньше строгих правил и тем больше явных проверок. Строгие правила в домене бесплатны именно потому, что все данные на входе уже провалидированы на границе.
Порядок внедрения
base.neonс общим набором дешёвых правил — один коммит на весь проект.- Разрезать пути на три конфига, каждому выставить тот уровень, который
проходит сегодня, и завести три задачи в CI с отдельными
tmpDir. - Поднимать уровень домена до 10 и включать
allRules: true— здесь основная работа, но и основная отдача. - Границы и legacy подтягивать по одному правилу за коммит, не поднимая уровень целиком.
Набор параметров конфигурации между мажорными версиями PHPStan
менялся: часть проверок (в том числе
checkAlwaysTrue*иcheckExplicitMixed) в 2.x переехала внутрь уровней, а одноимённые параметры из конфига убрали. Перед копированием конфига сверяйтесь с документацией под свою версию.