phpstan-drupal 2.1.0: stricter defaults
phpstan-drupal 2.1.0 is out. The theme of this release: rules and behaviors that proved themselves as opt-ins are now the defaults. If you run `composer update` and see new errors, that is the release working as intended — everything below includes the configuration to opt back out.
Nine rules are now enabled by default
These rules shipped as opt-ins over the 2.0 cycle. They have had time to bake, and they catch real bugs, so they no longer require configuration:
testClassSuffixNameRuledependencySerializationTraitPropertyRuleaccessResultConditionRulecacheableDependencyRulehookFormAlterRuleloggerFromFactoryPropertyAssignmentRuleentityStorageDirectInjectionRulesymfonyYamlParseRuleentityOperationsCacheabilityRule
Disable any of them individually if they don't fit your project:
parameters:
drupal:
rules:
cacheableDependencyRule: falseOne rename to watch for: hookRules is now hookFormAlterRule. If your phpstan.neon references the old key, PHPStan will reject the configuration until you rename it.
ContainerInterface::has() returns bool now
Previously, $container->has('some.service') resolved to always-true for any service the service map knew about. PHPStan then flagged the guard as redundant:
if ($this->container->has('some.service')) {
// "Comparison will always evaluate to true" — so you delete the guard.
$this->container->get('some.service')->doThing();
}That guard is not redundant. Modules get uninstalled. Site builds differ. The static analyzer knowing about a service does not guarantee the runtime container has it. Deleting the check trades a static-analysis warning for a production crash.
This behavior shipped in bleedingEdge.neon first and has now graduated: has() returns bool for known services by default. If you want the old inference back:
parameters:
drupal:
bleedingEdge:
containerHasAlwaysTrue: trueA three-year-old one-line fix: LoadIncludes
PR #587 from donquixote sat open since 2023. The LoadIncludes rule — which verifies $module_handler->loadInclude() points at a file that exists had its type check inverted:
// Before: bails on concrete ModuleHandler, fires on plain object/mixed.
if (!$type->isSuperTypeOf($moduleHandlerInterfaceType)->yes()) {
return [];
}
// After: fires only when the receiver is definitely a ModuleHandlerInterface.
if (!$moduleHandlerInterfaceType->isSuperTypeOf($type)->yes()) {
return [];
}The practical effect: code that type-hints the concrete ModuleHandler class was silently skipped for years. It gets checked now, so you may see new loadIncludes.* errors in code that never changed. Those are real findings, not regressions.
Opting out of class resolver narrowing
ClassResolverInterface::getInstanceFromDefinition(Foo::class) narrows to Foo. Usually right, but the class resolver checks the container first, and a service definition can substitute an entirely different class. With the narrowed type, this defensive assertion gets flagged as always-true:
$foo = $this->classResolver->getInstanceFromDefinition(Foo::class);
assert($foo instanceof Foo);The narrowing stays on by default because it is correct for the overwhelming majority of code. If you want your assertions to keep meaning something:
parameters:
drupal:
classResolverReturnType: falseSupporting 2.0.x
2.1.0 introduces new errors by default. So 2.0 is not abandoned.
If you hit a bug on 2.0.x, open an issue and note the version. When you are ready to move to 2.1, the release notes walk through every behavior change with its opt-out.
Thanks
donquixote for the LoadIncludes fix, Niklan for the detailed report that shaped classResolverReturnType, and Fame Helsinki for sponsoring the project. If phpstan-drupal saves your team time, consider sponsoring.