From d959997cca56f4786d1665086be001354349c98b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:14:32 +0000 Subject: [PATCH 1/7] Fix phpstan/phpstan#13380: Promoted property visibility change causes false uninitialized error - When a child class redeclares a parent's promoted property (e.g. to widen visibility), the property was incorrectly reported as uninitialized - Added check in ClassPropertiesNode::getUninitializedProperties() to detect when the inherited constructor's declaring class promotes the redeclared property - Added regression test with both the valid case (inherited constructor) and invalid case (own constructor without parent::__construct) --- src/Node/ClassPropertiesNode.php | 10 +++++++ .../UninitializedPropertyRuleTest.php | 10 +++++++ .../Rules/Properties/data/bug-13380.php | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-13380.php diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index f29a7cd9620..7a98ef0dd86 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -132,6 +132,16 @@ public function getUninitializedProperties( } $originalProperties[$property->getName()] = $property; $is = TrinaryLogic::createFromBoolean($property->isPromoted() && !$property->isPromotedFromTrait()); + if (!$is->yes() && $classReflection->hasConstructor()) { + $constructorDeclaringClass = $classReflection->getConstructor()->getDeclaringClass(); + if ( + $constructorDeclaringClass->getName() !== $classReflection->getName() + && $constructorDeclaringClass->hasNativeProperty($property->getName()) + && $constructorDeclaringClass->getNativeProperty($property->getName())->isPromoted() + ) { + $is = TrinaryLogic::createYes(); + } + } if (!$is->yes() && $classReflection->hasNativeProperty($property->getName())) { $propertyReflection = $classReflection->getNativeProperty($property->getName()); if ($propertyReflection->isVirtual()->yes()) { diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index 1aa54142672..3a0c6bead41 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -233,4 +233,14 @@ public function testBug12547(): void $this->analyse([__DIR__ . '/data/bug-12547.php'], []); } + public function testBug13380(): void + { + $this->analyse([__DIR__ . '/data/bug-13380.php'], [ + [ + 'Class Bug13380\Baz has an uninitialized property $prop. Give it default value or assign it in the constructor.', + 20, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13380.php b/tests/PHPStan/Rules/Properties/data/bug-13380.php new file mode 100644 index 00000000000..98a04c4a1a1 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13380.php @@ -0,0 +1,26 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug13380; + +class Foo +{ + public function __construct( + protected string $prop, + ){ + } +} + +class Bar extends Foo { + public string $prop; +} + +class Baz extends Foo { + public string $prop; + + public function __construct() + { + // Does not call parent::__construct, so $prop is uninitialized + } +} From 3837ad34c5dd32ae45d435b88ddadc9286338408 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 22:06:10 +0000 Subject: [PATCH 2/7] Walk ancestor chain to find promoted property in inherited constructor When an intermediate class declares its own constructor that calls parent::__construct(), the promoted property lives in a grandparent class. Walk up the ancestor chain from the constructor's declaring class instead of only checking that single class. Co-Authored-By: Claude Opus 4.6 --- src/Node/ClassPropertiesNode.php | 18 ++++++++++++------ .../UninitializedPropertyRuleTest.php | 2 +- .../Rules/Properties/data/bug-13380.php | 13 +++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index 7a98ef0dd86..77055276177 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -134,12 +134,18 @@ public function getUninitializedProperties( $is = TrinaryLogic::createFromBoolean($property->isPromoted() && !$property->isPromotedFromTrait()); if (!$is->yes() && $classReflection->hasConstructor()) { $constructorDeclaringClass = $classReflection->getConstructor()->getDeclaringClass(); - if ( - $constructorDeclaringClass->getName() !== $classReflection->getName() - && $constructorDeclaringClass->hasNativeProperty($property->getName()) - && $constructorDeclaringClass->getNativeProperty($property->getName())->isPromoted() - ) { - $is = TrinaryLogic::createYes(); + if ($constructorDeclaringClass->getName() !== $classReflection->getName()) { + $ancestor = $constructorDeclaringClass; + while ($ancestor !== null) { + if ( + $ancestor->hasNativeProperty($property->getName()) + && $ancestor->getNativeProperty($property->getName())->isPromoted() + ) { + $is = TrinaryLogic::createYes(); + break; + } + $ancestor = $ancestor->getParentClass(); + } } } if (!$is->yes() && $classReflection->hasNativeProperty($property->getName())) { diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index 3a0c6bead41..2cd9f8bb613 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -238,7 +238,7 @@ public function testBug13380(): void $this->analyse([__DIR__ . '/data/bug-13380.php'], [ [ 'Class Bug13380\Baz has an uninitialized property $prop. Give it default value or assign it in the constructor.', - 20, + 33, ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13380.php b/tests/PHPStan/Rules/Properties/data/bug-13380.php index 98a04c4a1a1..1f9775a6de2 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-13380.php +++ b/tests/PHPStan/Rules/Properties/data/bug-13380.php @@ -16,6 +16,19 @@ class Bar extends Foo { public string $prop; } +class Bar2 extends Foo +{ + public function __construct( + string $prop, + ){ + parent::__construct($prop); + } +} + +class Baz2 extends Bar2 { + public string $prop; +} + class Baz extends Foo { public string $prop; From fc95a7cc4753783f50be9553f0501c24a12a8d6d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 22:46:54 +0000 Subject: [PATCH 3/7] Report uninitialized property when inherited constructor doesn't promote it When an intermediate class overrides the constructor without a matching parameter for the promoted property, the ancestor walk now stops instead of incorrectly assuming the property is initialized. Uses constructor parameter matching as a heuristic to determine if parent::__construct is likely called. Co-Authored-By: Claude Opus 4.6 --- src/Node/ClassPropertiesNode.php | 54 +++++++++++++++---- .../UninitializedPropertyRuleTest.php | 4 ++ .../Rules/Properties/data/bug-13380.php | 19 +++++++ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index 77055276177..914e396bca0 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -135,17 +135,7 @@ public function getUninitializedProperties( if (!$is->yes() && $classReflection->hasConstructor()) { $constructorDeclaringClass = $classReflection->getConstructor()->getDeclaringClass(); if ($constructorDeclaringClass->getName() !== $classReflection->getName()) { - $ancestor = $constructorDeclaringClass; - while ($ancestor !== null) { - if ( - $ancestor->hasNativeProperty($property->getName()) - && $ancestor->getNativeProperty($property->getName())->isPromoted() - ) { - $is = TrinaryLogic::createYes(); - break; - } - $ancestor = $ancestor->getParentClass(); - } + $is = $this->isPropertyInitializedByConstructorChain($constructorDeclaringClass, $property->getName()); } } if (!$is->yes() && $classReflection->hasNativeProperty($property->getName())) { @@ -435,4 +425,46 @@ public function getPropertyAssigns(): array return $this->propertyAssigns; } + private function isPropertyInitializedByConstructorChain(ClassReflection $constructorDeclaringClass, string $propertyName): TrinaryLogic + { + $ancestor = $constructorDeclaringClass; + do { + $ancestorConstructor = $ancestor->getNativeReflection()->getConstructor(); + if ($ancestorConstructor !== null) { + foreach ($ancestorConstructor->getParameters() as $param) { + if ($param->getName() === $propertyName && $param->isPromoted()) { + return TrinaryLogic::createYes(); + } + } + } + + $parent = $ancestor->getParentClass(); + if ($parent === null) { + break; + } + + $hasOwnConstructor = $ancestor->hasConstructor() + && $ancestor->getConstructor()->getDeclaringClass()->getName() === $ancestor->getName(); + + if ($hasOwnConstructor) { + $hasMatchingParam = false; + if ($ancestorConstructor !== null) { + foreach ($ancestorConstructor->getParameters() as $param) { + if ($param->getName() === $propertyName) { + $hasMatchingParam = true; + break; + } + } + } + if (!$hasMatchingParam) { + break; + } + } + + $ancestor = $parent; + } while (true); + + return TrinaryLogic::createNo(); + } + } diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index 2cd9f8bb613..f1bdc9dc04c 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -240,6 +240,10 @@ public function testBug13380(): void 'Class Bug13380\Baz has an uninitialized property $prop. Give it default value or assign it in the constructor.', 33, ], + [ + 'Class Bug13380\Baz3 has an uninitialized property $prop. Give it default value or assign it in the constructor.', + 57, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13380.php b/tests/PHPStan/Rules/Properties/data/bug-13380.php index 1f9775a6de2..6be2ea5d814 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-13380.php +++ b/tests/PHPStan/Rules/Properties/data/bug-13380.php @@ -37,3 +37,22 @@ public function __construct() // Does not call parent::__construct, so $prop is uninitialized } } + +class Foo3 +{ + public function __construct( + protected string $prop, + ){ + } +} + +class Bar3 extends Foo3 +{ + public function __construct() + { + } +} + +class Baz3 extends Bar3 { + public string $prop; +} From 2a5544f10dae71b85da6f52e9acc470ad38300d9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 09:51:08 +0000 Subject: [PATCH 4/7] Do not treat private promoted properties as initializing redeclared child property Private promoted properties are not inherited by child classes, so a child class redeclaring the property should still report it as uninitialized. Co-Authored-By: Claude Opus 4.6 --- src/Node/ClassPropertiesNode.php | 5 ++++- .../Properties/UninitializedPropertyRuleTest.php | 4 ++++ tests/PHPStan/Rules/Properties/data/bug-13380.php | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index 914e396bca0..6c7aa987d31 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -433,7 +433,10 @@ private function isPropertyInitializedByConstructorChain(ClassReflection $constr if ($ancestorConstructor !== null) { foreach ($ancestorConstructor->getParameters() as $param) { if ($param->getName() === $propertyName && $param->isPromoted()) { - return TrinaryLogic::createYes(); + $ancestorNativeReflection = $ancestor->getNativeReflection(); + if ($ancestorNativeReflection->hasProperty($propertyName) && !$ancestorNativeReflection->getProperty($propertyName)->isPrivate()) { + return TrinaryLogic::createYes(); + } } } } diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index f1bdc9dc04c..ccf5966534b 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -244,6 +244,10 @@ public function testBug13380(): void 'Class Bug13380\Baz3 has an uninitialized property $prop. Give it default value or assign it in the constructor.', 57, ], + [ + 'Class Bug13380\BarPrivate has an uninitialized property $prop. Give it default value or assign it in the constructor.', + 69, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13380.php b/tests/PHPStan/Rules/Properties/data/bug-13380.php index 6be2ea5d814..d5c961e7707 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-13380.php +++ b/tests/PHPStan/Rules/Properties/data/bug-13380.php @@ -56,3 +56,15 @@ public function __construct() class Baz3 extends Bar3 { public string $prop; } + +class FooPrivate +{ + public function __construct( + private string $prop, + ){ + } +} + +class BarPrivate extends FooPrivate { + public string $prop; +} From f6ab978f07767b3d48d145fd841eab7b25dbcbe1 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 12:01:15 +0200 Subject: [PATCH 5/7] Rename --- src/Node/ClassPropertiesNode.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index 6c7aa987d31..9d199a272c9 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -135,7 +135,7 @@ public function getUninitializedProperties( if (!$is->yes() && $classReflection->hasConstructor()) { $constructorDeclaringClass = $classReflection->getConstructor()->getDeclaringClass(); if ($constructorDeclaringClass->getName() !== $classReflection->getName()) { - $is = $this->isPropertyInitializedByConstructorChain($constructorDeclaringClass, $property->getName()); + $is = $this->isPromotedByConstructorChain($constructorDeclaringClass, $property->getName()); } } if (!$is->yes() && $classReflection->hasNativeProperty($property->getName())) { @@ -425,7 +425,7 @@ public function getPropertyAssigns(): array return $this->propertyAssigns; } - private function isPropertyInitializedByConstructorChain(ClassReflection $constructorDeclaringClass, string $propertyName): TrinaryLogic + private function isPromotedByConstructorChain(ClassReflection $constructorDeclaringClass, string $propertyName): TrinaryLogic { $ancestor = $constructorDeclaringClass; do { From 6f2030862fce44589320da53325d2306760325bc Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 12:02:15 +0200 Subject: [PATCH 6/7] Fix lint --- src/Node/ClassPropertiesNode.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index 9d199a272c9..d5d43a81f55 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -432,11 +432,13 @@ private function isPromotedByConstructorChain(ClassReflection $constructorDeclar $ancestorConstructor = $ancestor->getNativeReflection()->getConstructor(); if ($ancestorConstructor !== null) { foreach ($ancestorConstructor->getParameters() as $param) { - if ($param->getName() === $propertyName && $param->isPromoted()) { - $ancestorNativeReflection = $ancestor->getNativeReflection(); - if ($ancestorNativeReflection->hasProperty($propertyName) && !$ancestorNativeReflection->getProperty($propertyName)->isPrivate()) { - return TrinaryLogic::createYes(); - } + if ($param->getName() !== $propertyName || !$param->isPromoted()) { + continue; + } + + $ancestorNativeReflection = $ancestor->getNativeReflection(); + if ($ancestorNativeReflection->hasProperty($propertyName) && !$ancestorNativeReflection->getProperty($propertyName)->isPrivate()) { + return TrinaryLogic::createYes(); } } } From a93e756430a175b307b9b9294e438a3f8297c08e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 10:39:40 +0000 Subject: [PATCH 7/7] Handle non-promoted properties and additional constructors for inherited initialization When a child class redeclares a property from a parent class, also consider it initialized if the constructor-declaring ancestor class declares the same non-private property (not just promoted parameters). Also handle inherited additional constructors (e.g. setUp()) the same way. Co-Authored-By: Claude Opus 4.6 --- src/Node/ClassPropertiesNode.php | 36 +++++++++++++++++ .../UninitializedPropertyRuleTest.php | 8 ++++ .../Rules/Properties/data/bug-13380.php | 39 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index d5d43a81f55..98bb5fbc39a 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -136,6 +136,28 @@ public function getUninitializedProperties( $constructorDeclaringClass = $classReflection->getConstructor()->getDeclaringClass(); if ($constructorDeclaringClass->getName() !== $classReflection->getName()) { $is = $this->isPromotedByConstructorChain($constructorDeclaringClass, $property->getName()); + if (!$is->yes()) { + $is = $this->isPropertyDeclaredInAncestor($constructorDeclaringClass, $property->getName()); + } + } + } + if (!$is->yes()) { + foreach ($constructors as $constructorName) { + if (strtolower($constructorName) === '__construct') { + continue; + } + if (!$classReflection->hasNativeMethod($constructorName)) { + continue; + } + $methodReflection = $classReflection->getNativeMethod($constructorName); + $declaringClass = $methodReflection->getDeclaringClass(); + if ($declaringClass->getName() === $classReflection->getName()) { + continue; + } + $is = $this->isPropertyDeclaredInAncestor($declaringClass, $property->getName()); + if ($is->yes()) { + break; + } } } if (!$is->yes() && $classReflection->hasNativeProperty($property->getName())) { @@ -425,6 +447,20 @@ public function getPropertyAssigns(): array return $this->propertyAssigns; } + private function isPropertyDeclaredInAncestor(ClassReflection $ancestorClass, string $propertyName): TrinaryLogic + { + $nativeReflection = $ancestorClass->getNativeReflection(); + if ( + $nativeReflection->hasProperty($propertyName) + && !$nativeReflection->getProperty($propertyName)->isPrivate() + && $nativeReflection->getProperty($propertyName)->getDeclaringClass()->getName() === $ancestorClass->getName() + ) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); + } + private function isPromotedByConstructorChain(ClassReflection $constructorDeclaringClass, string $propertyName): TrinaryLogic { $ancestor = $constructorDeclaringClass; diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index ccf5966534b..352f31cc3fd 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -248,6 +248,14 @@ public function testBug13380(): void 'Class Bug13380\BarPrivate has an uninitialized property $prop. Give it default value or assign it in the constructor.', 69, ], + [ + 'Class Bug13380\BazBody has an uninitialized property $prop. Give it default value or assign it in the constructor.', + 88, + ], + [ + 'Class Bug13380\FooBodyNoInit has an uninitialized property $prop. Give it default value or assign it in the constructor.', + 99, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13380.php b/tests/PHPStan/Rules/Properties/data/bug-13380.php index d5c961e7707..94abdacb8db 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-13380.php +++ b/tests/PHPStan/Rules/Properties/data/bug-13380.php @@ -68,3 +68,42 @@ public function __construct( class BarPrivate extends FooPrivate { public string $prop; } + +// Non-promoted property initialized in parent constructor body +class FooBody +{ + protected string $prop; + + public function __construct() + { + $this->prop = "1232"; + } +} + +class BarBody extends FooBody { + public string $prop; +} + +class BazBody extends FooBody { + public string $prop; + + public function __construct() + { + // Does not call parent::__construct, so $prop is uninitialized + } +} + +// Non-promoted property NOT initialized in parent constructor body +class FooBodyNoInit +{ + protected string $prop; + + public function __construct() + { + // doesn't initialize $prop + } +} + +class BarBodyNoInit extends FooBodyNoInit { + public string $prop; +}