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) на границе; если конфликтует часто, на этом слое его проще выключить.

Правило разделения простое: чем ближе код к внешнему миру, тем

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

Порядок внедрения

  1. base.neon с общим набором дешёвых правил — один коммит на весь проект.
  2. Разрезать пути на три конфига, каждому выставить тот уровень, который проходит сегодня, и завести три задачи в CI с отдельными tmpDir.
  3. Поднимать уровень домена до 10 и включать allRules: true — здесь основная работа, но и основная отдача.
  4. Границы и legacy подтягивать по одному правилу за коммит, не поднимая уровень целиком.

Набор параметров конфигурации между мажорными версиями PHPStan

менялся: часть проверок (в том числе checkAlwaysTrue* и checkExplicitMixed) в 2.x переехала внутрь уровней, а одноимённые параметры из конфига убрали. Перед копированием конфига сверяйтесь с документацией под свою версию.

🔗 Связанные заметки