From cc2996507975f926e687fe5b125640211e894714 Mon Sep 17 00:00:00 2001 From: Jean-Beru Date: Thu, 6 Aug 2026 17:46:29 +0200 Subject: [PATCH 1/4] Do not report a private method overriding a private trait method as unused A class member takes precedence over the member of the same name coming from a used trait, so a private method redeclared in the class is what the trait's own methods call. The rule sees those call sites only when the trait is part of the analysed files: analysing a project whose paths do not include its dependencies leaves ClassMethodsNode::getMethodCalls() with no call at all, and the class method is reported as unused. The canonical case is a Symfony application, where the framework recipe generates a Kernel redeclaring KernelTrait::getAllowedEnvs() while the trait lives in vendor/, outside of the analysed paths. Skip those methods. Nothing is left to distinguish an override that the trait calls from one it does not, so a redeclared private method the trait never calls is no longer reported either. Fixes phpstan/phpstan#12201 Assisted-by: Claude Code:claude-opus-5 --- .../DeadCode/UnusedPrivateMethodRule.php | 27 +++++++++++++++++++ .../DeadCode/UnusedPrivateMethodRuleTest.php | 10 +++++++ .../Rules/DeadCode/data/bug-12201-traits.php | 27 +++++++++++++++++++ .../PHPStan/Rules/DeadCode/data/bug-12201.php | 27 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-12201.php diff --git a/src/Rules/DeadCode/UnusedPrivateMethodRule.php b/src/Rules/DeadCode/UnusedPrivateMethodRule.php index 4a442bf489d..eb51ce964df 100644 --- a/src/Rules/DeadCode/UnusedPrivateMethodRule.php +++ b/src/Rules/DeadCode/UnusedPrivateMethodRule.php @@ -9,6 +9,7 @@ use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\ClassMethodsNode; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Methods\AlwaysUsedMethodExtension; use PHPStan\Rules\Rule; @@ -70,6 +71,10 @@ public function processNode(Node $node, Scope $scope): array continue; } + if ($this->isOverridingPrivateTraitMethod($classReflection, $methodName)) { + continue; + } + $methodReflection = $classReflection->getNativeMethod($methodName); foreach ($this->extensions->getAll() as $extension) { if ($extension->isAlwaysUsed($methodReflection)) { @@ -203,4 +208,26 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + /** + * A private method overriding a private method of a used trait is called from + * the trait's own methods. Those call sites are invisible when the trait is not + * part of the analysed files. + */ + private function isOverridingPrivateTraitMethod(ClassReflection $classReflection, string $methodName): bool + { + foreach ($classReflection->getTraits() as $trait) { + if (!$trait->hasNativeMethod($methodName)) { + continue; + } + + if (!$trait->getNativeMethod($methodName)->isPrivate()) { + continue; + } + + return true; + } + + return false; + } + } diff --git a/tests/PHPStan/Rules/DeadCode/UnusedPrivateMethodRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedPrivateMethodRuleTest.php index f57d00b9e5f..ccf290cc5f9 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedPrivateMethodRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedPrivateMethodRuleTest.php @@ -134,6 +134,16 @@ public function testBug11802(): void ]); } + public function testBug12201(): void + { + $this->analyse([__DIR__ . '/data/bug-12201.php'], [ + [ + 'Method Bug12201\AnotherKernel::doNothing() is unused.', + 24, + ], + ]); + } + public function testBug14880(): void { $this->analyse([__DIR__ . '/data/bug-14880.php'], []); diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php new file mode 100644 index 00000000000..bcf859eae81 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php @@ -0,0 +1,27 @@ +getAllowedEnvs(); + } +} + +trait MicroKernelTrait +{ + use KernelTrait; +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201.php new file mode 100644 index 00000000000..cc655543a71 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201.php @@ -0,0 +1,27 @@ + + */ + private function getAllowedEnvs(): array + { + return ['prod', 'dev', 'test']; + } +} + +class AnotherKernel +{ + use MicroKernelTrait; + + private function doNothing(): void + { + } +} From bed4221d9174dff5c224061879ee3607d36857c9 Mon Sep 17 00:00:00 2001 From: Jean-Beru Date: Mon, 28 Sep 2026 09:50:49 +0200 Subject: [PATCH 2/4] Do not report a private constant redeclared from a private trait constant as unused PHP merges a class constant with the same-named constant of a used trait into a single slot, so the trait's own methods fetch the class' declaration. The rule sees those fetches only when the trait's body is traversed, which NodeScopeResolver does for analysed files only: analysing a project whose paths do not include its dependencies leaves ClassConstantsNode with no fetch at all, and the class constant is reported as unused. Skip those constants, but only when no ClassConst node was gathered from a trait body for that name. Such a node proves the trait was traversed and its fetches are visible, so a redeclaration whose trait lives inside the analysed paths keeps being judged on what the code actually does. The residual cost is a constant redeclared from a trait outside the analysed paths that the trait never fetches: it is no longer reported, and nothing is left to distinguish it from one the trait does fetch. Assisted-by: Claude Code:claude-opus-5 --- .../DeadCode/UnusedPrivateConstantRule.php | 46 ++++++++++++ .../UnusedPrivateConstantRuleTest.php | 27 +++++++ .../data/bug-12201-constant-traits.php | 27 +++++++ .../DeadCode/data/bug-12201-constant.php | 71 +++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-12201-constant.php diff --git a/src/Rules/DeadCode/UnusedPrivateConstantRule.php b/src/Rules/DeadCode/UnusedPrivateConstantRule.php index 5a0f01f3acb..7fccdbacb9b 100644 --- a/src/Rules/DeadCode/UnusedPrivateConstantRule.php +++ b/src/Rules/DeadCode/UnusedPrivateConstantRule.php @@ -8,10 +8,13 @@ use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Node\ClassConstantsNode; +use PHPStan\Reflection\ClassReflection; use PHPStan\Rules\Constants\AlwaysUsedClassConstantsExtension; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\ObjectType; +use function array_key_exists; +use function in_array; use function sprintf; /** @@ -45,6 +48,20 @@ public function processNode(Node $node, Scope $scope): array $classReflection = $node->getClassReflection(); $classType = new ObjectType($classReflection->getName(), classReflection: $classReflection); + // A gathered ClassConst that is not one of the class' own statements comes from an + // inlined trait body, which NodeScopeResolver only traverses for analysed files. + // Its presence therefore proves the trait's own fetches are visible. + $constantNamesDeclaredInTraitBody = []; + foreach ($node->getConstants() as $constant) { + if (in_array($constant, $node->getClass()->stmts, true)) { + continue; + } + + foreach ($constant->consts as $const) { + $constantNamesDeclaredInTraitBody[$const->name->toString()] = true; + } + } + $constants = []; foreach ($node->getConstants() as $constant) { if (!$constant->isPrivate()) { @@ -54,6 +71,13 @@ public function processNode(Node $node, Scope $scope): array foreach ($constant->consts as $const) { $constantName = $const->name->toString(); + if ( + !array_key_exists($constantName, $constantNamesDeclaredInTraitBody) + && $this->isRedeclaringPrivateTraitConstant($classReflection, $constantName) + ) { + continue; + } + $constantReflection = $classReflection->getConstant($constantName); foreach ($this->extensions->getAll() as $extension) { if ($extension->isAlwaysUsed($constantReflection)) { @@ -113,4 +137,26 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + /** + * A private constant redeclared from a used trait is the very constant the trait's + * own methods fetch. Callers must only rely on this when the trait's body was not + * traversed, otherwise those fetches are visible and no guessing is needed. + */ + private function isRedeclaringPrivateTraitConstant(ClassReflection $classReflection, string $constantName): bool + { + foreach ($classReflection->getTraits() as $trait) { + if (!$trait->hasConstant($constantName)) { + continue; + } + + if (!$trait->getConstant($constantName)->isPrivate()) { + continue; + } + + return true; + } + + return false; + } + } diff --git a/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php index 46d72f8c78a..944c758a947 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedPrivateConstantRuleTest.php @@ -105,4 +105,31 @@ public function testBug14880(): void $this->analyse([__DIR__ . '/data/bug-14880-constant.php'], []); } + #[RequiresPhp('>= 8.2.0')] + public function testBug12201(): void + { + $this->analyse([__DIR__ . '/data/bug-12201-constant.php'], [ + [ + 'Constant Bug12201Constant\\AnotherKernel::UNUSED is unused.', + 23, + 'See: https://phpstan.org/developing-extensions/always-used-class-constants', + ], + [ + 'Constant Bug12201Constant\\UsesNeverFetchedTrait::NEVER_FETCHED is unused.', + 30, + 'See: https://phpstan.org/developing-extensions/always-used-class-constants', + ], + [ + 'Constant Bug12201Constant\\ChildKernel::ALLOWED_ENVS is unused.', + 52, + 'See: https://phpstan.org/developing-extensions/always-used-class-constants', + ], + [ + 'Constant Bug12201Constant\\RedeclaresAnalysedTrait::REDECLARED is unused.', + 69, + 'See: https://phpstan.org/developing-extensions/always-used-class-constants', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php new file mode 100644 index 00000000000..1ac7c2462d8 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php @@ -0,0 +1,27 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug12201Constant; + +trait KernelTrait +{ + + private const ALLOWED_ENVS = ['prod', 'dev', 'test']; + + /** + * @return list + */ + protected function getAllowedEnvs(): array + { + return self::ALLOWED_ENVS; + } + +} + +trait MicroKernelTrait +{ + + use KernelTrait; + +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant.php new file mode 100644 index 00000000000..f4edc4e2404 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant.php @@ -0,0 +1,71 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug12201Constant; + +// The traits live in bug-12201-constant-traits.php which is not analysed on purpose: +// it stands for a dependency living outside of the analysed paths. +class AppKernel +{ + + use MicroKernelTrait; + + private const ALLOWED_ENVS = ['prod', 'dev', 'test']; + +} + +class AnotherKernel +{ + + use MicroKernelTrait; + + private const UNUSED = 'unused'; + +} + +trait NeverFetchedTrait +{ + + private const NEVER_FETCHED = 'never fetched'; + +} + +class UsesNeverFetchedTrait +{ + + use NeverFetchedTrait; + +} + +class ParentKernel +{ + + use MicroKernelTrait; + +} + +// The trait is used by the parent, so it cannot reach this separate private slot. +class ChildKernel extends ParentKernel +{ + + private const ALLOWED_ENVS = ['prod', 'dev', 'test']; + +} + +trait RedeclaredTrait +{ + + private const REDECLARED = 'redeclared'; + +} + +// This trait is analysed, so its fetches are visible and nothing needs to be assumed. +class RedeclaresAnalysedTrait +{ + + use RedeclaredTrait; + + private const REDECLARED = 'redeclared'; + +} From d171b76a6056ef9b52da51ae12b52b59774bdf27 Mon Sep 17 00:00:00 2001 From: Jean-Beru Date: Mon, 28 Sep 2026 09:50:58 +0200 Subject: [PATCH 3/4] Do not report a private property redeclared from a private trait property as unused PHP merges a class property with the same-named property of a used trait into a single slot, so the trait's own methods read and write the class' declaration. The rule sees those usages only when the trait's body is traversed, which NodeScopeResolver does for analysed files only: analysing a project whose paths do not include its dependencies leaves ClassPropertiesNode with no usage at all, and the class property is reported as never read. Skip those properties, but only when no property node declared in a trait was gathered for that name. Such a node proves the trait was traversed and its usages are visible, so a redeclaration whose trait lives inside the analysed paths keeps being judged on what the code actually does. The residual cost is a property redeclared from a trait outside the analysed paths that the trait never reads: it is no longer reported, and nothing is left to distinguish it from one the trait does read. Assisted-by: Claude Code:claude-opus-5 --- .../DeadCode/UnusedPrivatePropertyRule.php | 41 +++++++++ .../UnusedPrivatePropertyRuleTest.php | 24 ++++++ .../data/bug-12201-property-traits.php | 42 ++++++++++ .../DeadCode/data/bug-12201-property.php | 84 +++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-12201-property.php diff --git a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php index 66ccc951b04..f1176e89887 100644 --- a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php +++ b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php @@ -11,6 +11,7 @@ use PHPStan\Node\ClassPropertiesNode; use PHPStan\Node\ClassPropertyNode; use PHPStan\Node\Property\PropertyRead; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Rules\Properties\ReadWritePropertiesExtension; @@ -64,6 +65,18 @@ public function processNode(Node $node, Scope $scope): array } $classReflection = $node->getClassReflection(); $classType = new ObjectType($classReflection->getName(), classReflection: $classReflection); + // A property node declared in a trait only reaches us when NodeScopeResolver traversed + // that trait's body, which it does for analysed files only. Its presence therefore + // proves the trait's own usages are visible. + $propertyNamesDeclaredInTraitBody = []; + foreach ($node->getProperties() as $property) { + if (!$property->isDeclaredInTrait()) { + continue; + } + + $propertyNamesDeclaredInTraitBody[$property->getName()] = true; + } + $properties = []; foreach ($node->getProperties() as $property) { if (!$property->isPrivate()) { @@ -72,6 +85,12 @@ public function processNode(Node $node, Scope $scope): array if ($property->isDeclaredInTrait()) { continue; } + if ( + !array_key_exists($property->getName(), $propertyNamesDeclaredInTraitBody) + && $this->isRedeclaringPrivateTraitProperty($classReflection, $property->getName()) + ) { + continue; + } $alwaysRead = !$property->isReadable(); $alwaysWritten = !$property->isWritable(); @@ -293,6 +312,28 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + /** + * A private property redeclared from a used trait is the very property the trait's + * own methods read and write. Callers must only rely on this when the trait's body was + * not traversed, otherwise those usages are visible and no guessing is needed. + */ + private function isRedeclaringPrivateTraitProperty(ClassReflection $classReflection, string $propertyName): bool + { + foreach ($classReflection->getTraits() as $trait) { + if (!$trait->hasNativeProperty($propertyName)) { + continue; + } + + if (!$trait->getNativeProperty($propertyName)->isPrivate()) { + continue; + } + + return true; + } + + return false; + } + private function isPropertySelfWrite( Scope $usageScope, string $propertyName, diff --git a/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php index d724318377a..0bbef747ba9 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php @@ -483,4 +483,28 @@ public function testBug14880(): void $this->analyse([__DIR__ . '/data/bug-14880-property.php'], []); } + public function testBug12201(): void + { + $this->alwaysWrittenTags = []; + $this->alwaysReadTags = []; + + $this->analyse([__DIR__ . '/data/bug-12201-property.php'], [ + [ + 'Property Bug12201Property\\AnotherKernel::$unused is never read, only written.', + 22, + 'See: https://phpstan.org/developing-extensions/always-read-written-properties', + ], + [ + 'Property Bug12201Property\\ChildKernel::$allowedEnvs is never read, only written.', + 48, + 'See: https://phpstan.org/developing-extensions/always-read-written-properties', + ], + [ + 'Property Bug12201Property\\RedeclaresAnalysedTrait::$redeclared is never read, only written.', + 82, + 'See: https://phpstan.org/developing-extensions/always-read-written-properties', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php new file mode 100644 index 00000000000..e29d70b99f5 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php @@ -0,0 +1,42 @@ + */ + private array $allowedEnvs = []; + + /** + * @return list + */ + protected function getAllowedEnvs(): array + { + return $this->allowedEnvs; + } + +} + +trait MicroKernelTrait +{ + + use KernelTrait; + +} + +trait StaticKernelTrait +{ + + /** @var list */ + private static array $staticAllowedEnvs = []; + + /** + * @return list + */ + protected static function getStaticAllowedEnvs(): array + { + return self::$staticAllowedEnvs; + } + +} diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-property.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-property.php new file mode 100644 index 00000000000..9a3e75aea55 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-property.php @@ -0,0 +1,84 @@ + */ + private array $allowedEnvs = []; + +} + +class AnotherKernel +{ + + use MicroKernelTrait; + + private ?string $unused = null; + +} + +class StaticKernel +{ + + use StaticKernelTrait; + + /** @var list */ + private static array $staticAllowedEnvs = []; + +} + +class ParentKernel +{ + + use MicroKernelTrait; + +} + +// The trait is used by the parent, so it cannot reach this separate private slot. +class ChildKernel extends ParentKernel +{ + + /** @var list */ + private array $allowedEnvs = []; + +} + +trait NeverReadTrait +{ + + /** @var list */ + private array $neverRead = []; + +} + +class UsesNeverReadTrait +{ + + use NeverReadTrait; + +} + +trait RedeclaredTrait +{ + + /** @var list */ + private array $redeclared = []; + +} + +// This trait is analysed, so its usages are visible and nothing needs to be assumed. +class RedeclaresAnalysedTrait +{ + + use RedeclaredTrait; + + /** @var list */ + private array $redeclared = []; + +} From 12c374c355f459504d76f38a553a6e027ecef6b0 Mon Sep 17 00:00:00 2001 From: Jean-Beru Date: Mon, 28 Sep 2026 13:38:25 +0200 Subject: [PATCH 4/4] Say in each trait fixture why it is not analysed The three *-traits.php fixtures stand for a dependency living outside of the analysed paths, which is the whole point of the reproducers: PHPStan never traverses the trait bodies, so the trait's own calls, fetches and property usages stay invisible. Only the consumer files said so. Assisted-by: Claude Code:claude-opus-5 --- .../PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php | 3 +++ .../PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php | 3 +++ tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php | 3 +++ 3 files changed, 9 insertions(+) diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php index 1ac7c2462d8..118ab28ad5e 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-constant-traits.php @@ -4,6 +4,9 @@ namespace Bug12201Constant; +// Stands for a dependency shipped in vendor/: bug-12201-constant.php uses these traits, but +// this file is never passed to analyse(), so PHPStan does not traverse the trait bodies. + trait KernelTrait { diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php index e29d70b99f5..89a34ddf7b9 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-property-traits.php @@ -2,6 +2,9 @@ namespace Bug12201Property; +// Stands for a dependency shipped in vendor/: bug-12201-property.php uses these traits, but +// this file is never passed to analyse(), so PHPStan does not traverse the trait bodies. + trait KernelTrait { diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php b/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php index bcf859eae81..626a6a9f3e9 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-12201-traits.php @@ -2,6 +2,9 @@ namespace Bug12201; +// Stands for a dependency shipped in vendor/: bug-12201.php uses these traits, but +// this file is never passed to analyse(), so PHPStan does not traverse the trait bodies. + trait KernelTrait { /**