From ff4b31b17c42ff8ce578952b1b0985a4a8171ad6 Mon Sep 17 00:00:00 2001 From: Peter Potrowl Date: Tue, 18 Aug 2026 22:20:20 +0200 Subject: [PATCH 1/2] Renamed trait constructor promotes properties when called Constructor promotion is not limited to methods named __construct, so calling a trait constructor imported under a different name initializes its promoted properties in the caller's scope. --- .../ExprHandler/MethodCallHandler.php | 46 ++++++ .../MissingReadOnlyPropertyAssignRuleTest.php | 27 ++++ .../UninitializedPropertyRuleTest.php | 11 ++ .../Rules/Properties/data/bug-9789.php | 134 ++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-9789.php diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 8c2a5754ec3..11dcd580330 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -31,6 +31,7 @@ use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\InvalidateExprNode; use PHPStan\Reflection\Callables\SimpleImpurePoint; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; @@ -43,6 +44,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; +use function array_key_exists; use function array_map; use function array_merge; use function count; @@ -56,6 +58,9 @@ final class MethodCallHandler implements ExprHandler { + /** @var array> */ + private array $promotedParameterNamesCache = []; + public function __construct( private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, @@ -203,6 +208,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + if ( + !$methodReflection->isStatic() + && $scope->isInClass() + && $scope->getClassReflection()->getName() === $methodReflection->getDeclaringClass()->getName() + ) { + $calledOnType = $scope->getType($normalizedExpr->var); + foreach ($this->getPromotedParameterNames($methodReflection) as $propertyName) { + $scope = $scope->assignInitializedProperty($calledOnType, $propertyName); + } + } + } else { $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true); @@ -375,4 +391,34 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); } + /** + * Constructor promotion is not limited to methods called __construct: a trait + * constructor imported under a different name (use T { __construct as init; }) + * keeps promoting its parameters, so calling it initializes those properties. + * + * @return list + */ + private function getPromotedParameterNames(ExtendedMethodReflection $methodReflection): array + { + $declaringClass = $methodReflection->getDeclaringClass(); + $cacheKey = sprintf('%s::%s', $declaringClass->getName(), $methodReflection->getName()); + if (array_key_exists($cacheKey, $this->promotedParameterNamesCache)) { + return $this->promotedParameterNamesCache[$cacheKey]; + } + + $names = []; + $nativeClassReflection = $declaringClass->getNativeReflection(); + if ($nativeClassReflection->hasMethod($methodReflection->getName())) { + foreach ($nativeClassReflection->getMethod($methodReflection->getName())->getParameters() as $parameter) { + if (!$parameter->isPromoted()) { + continue; + } + + $names[] = $parameter->getName(); + } + } + + return $this->promotedParameterNamesCache[$cacheKey] = $names; + } + } diff --git a/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php index 6e9a1b98c9a..97831f46f61 100644 --- a/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php @@ -222,6 +222,33 @@ public function testBug7649(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testBug9789(): void + { + $this->analyse([__DIR__ . '/data/bug-9789.php'], [ + [ + 'Class Bug9789\InitNeverCalled has an uninitialized readonly property $value. Assign it in the constructor.', + 6, + ], + [ + 'Class Bug9789\InitOnAnotherObject has an uninitialized readonly property $value. Assign it in the constructor.', + 6, + ], + [ + 'Access to an uninitialized readonly property Bug9789\ReadBeforeInit::$value.', + 51, + ], + [ + 'Access to an uninitialized readonly property Bug9789\ConditionalInit::$value.', + 69, + ], + [ + 'Access to an uninitialized readonly property Bug9789\InitOnAnotherObject::$value.', + 97, + ], + ]); + } + #[RequiresPhp('>= 8.1.0')] public function testBug9577(): void { diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index b89b1e75959..bf2e33b5d96 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -240,4 +240,15 @@ public function testBug14983(): void $this->analyse([__DIR__ . '/data/bug-14983-uninitialized.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testBug9789(): void + { + $this->analyse([__DIR__ . '/data/bug-9789.php'], [ + [ + 'Access to an uninitialized property Bug9789\NotReadOnlyReadBeforeInit::$value.', + 130, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-9789.php b/tests/PHPStan/Rules/Properties/data/bug-9789.php new file mode 100644 index 00000000000..c4ac0f4c2fe --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-9789.php @@ -0,0 +1,134 @@ += 8.1 + +namespace Bug9789; + +trait T { + public function __construct(public readonly string $value) {} +} + +class C { + + use T { + __construct as protected init; + } + + public function __construct(string $value) { + $this->init($value); + if (!$this->isValid()) { + throw new \Exception(); + } + } + + private function isValid(): bool { + return !empty($this->value); + } +} + +class ReadInConstructor +{ + + use T { + __construct as protected init; + } + + public function __construct(string $value) + { + $this->init($value); + echo $this->value; + } + +} + +class ReadBeforeInit +{ + + use T { + __construct as protected init; + } + + public function __construct(string $value) + { + echo $this->value; + $this->init($value); + } + +} + +class ConditionalInit +{ + + use T { + __construct as protected init; + } + + public function __construct(string $value, bool $condition) + { + if ($condition) { + $this->init($value); + } + echo $this->value; + } + +} + +class InitNeverCalled +{ + + use T { + __construct as protected init; + } + + public function __construct(public int $code) + { + } + +} + +class InitOnAnotherObject +{ + + use T { + __construct as public init; + } + + public function __construct(string $value, self $other) + { + $other->init($value); + echo $this->value; + } + +} + +trait NotReadOnlyT { + public function __construct(public string $value) {} +} + +class NotReadOnly +{ + + use NotReadOnlyT { + __construct as protected init; + } + + public function __construct(string $value) + { + $this->init($value); + echo $this->value; + } + +} + +class NotReadOnlyReadBeforeInit +{ + + use NotReadOnlyT { + __construct as protected init; + } + + public function __construct(string $value) + { + echo $this->value; + $this->init($value); + } + +} From 0e43751e595ddf9e67eb6e4763442d0f906a2d30 Mon Sep 17 00:00:00 2001 From: Peter Potrowl Date: Wed, 19 Aug 2026 21:00:07 +0200 Subject: [PATCH 2/2] Apply comments from PR review. --- .../ExprHandler/MethodCallHandler.php | 4 +++- .../MissingReadOnlyPropertyAssignRuleTest.php | 1 + .../Rules/Properties/data/bug-9789.php | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 11dcd580330..ba33f556e07 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -213,7 +213,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $scope->isInClass() && $scope->getClassReflection()->getName() === $methodReflection->getDeclaringClass()->getName() ) { - $calledOnType = $scope->getType($normalizedExpr->var); + // $calledOnType is the receiver before the arguments were evaluated, which is + // the object the call goes to: an argument reassigning the receiver variable + // does not change who gets called. foreach ($this->getPromotedParameterNames($methodReflection) as $propertyName) { $scope = $scope->assignInitializedProperty($calledOnType, $propertyName); } diff --git a/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php index 97831f46f61..b76b1cebf60 100644 --- a/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php @@ -28,6 +28,7 @@ protected function getRule(): Rule 'Bug10523\\MultipleWrites::init', 'Bug10523\\SingleWriteInConstructorCalledMethod::init', 'Bug12253\\PayloadWithAdditionalConstructor::setUp', + 'Bug9789\\WithAdditionalConstructor::setUp', ], ), ); diff --git a/tests/PHPStan/Rules/Properties/data/bug-9789.php b/tests/PHPStan/Rules/Properties/data/bug-9789.php index c4ac0f4c2fe..fa7298ec6e7 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-9789.php +++ b/tests/PHPStan/Rules/Properties/data/bug-9789.php @@ -132,3 +132,23 @@ public function __construct(string $value) } } + +class WithAdditionalConstructor +{ + + use T { + __construct as protected init; + } + + protected function setUp(): void + { + $this->init('x'); + echo $this->readValue(); + } + + private function readValue(): string + { + return $this->value; + } + +}