From c6b5dd56810f2414cd2cb84d9efcb0a336710759 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:55:22 +0000 Subject: [PATCH 1/4] Fix phpstan/phpstan#13473: isset() false positive in property set hook - In property set hooks, the hooked property might not be initialized yet (e.g., when the set hook is triggered from __construct()) - Removed PropertyInitializationExpr for the hooked property from the scope when entering a set hook in MutatingScope::enterPropertyHook() - Added regression test in tests/PHPStan/Rules/Variables/data/bug-13473.php --- src/Analyser/MutatingScope.php | 10 ++++++++- .../PHPStan/Rules/Variables/IssetRuleTest.php | 8 +++++++ .../Rules/Variables/data/bug-13473.php | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13473.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index de3c4179b22..ad76e4ab10c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1574,7 +1574,7 @@ public function enterPropertyHook( $realParameterTypes = $this->getRealParameterTypes($hook); - return $this->enterFunctionLike( + $scope = $this->enterFunctionLike( new PhpMethodFromParserNodeReflection( $this->getClassReflection(), $hook, @@ -1606,6 +1606,14 @@ public function enterPropertyHook( ), true, ); + + if ($hookName === 'set') { + $initExprKey = $this->getNodeKey(new PropertyInitializationExpr($propertyName)); + unset($scope->expressionTypes[$initExprKey]); + unset($scope->nativeExpressionTypes[$initExprKey]); + } + + return $scope; } private function transformStaticType(Type $type): Type diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index fd841e49b16..f69d783c42f 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -562,4 +562,12 @@ public function testBug14393(): void ]); } + #[RequiresPhp('>= 8.4')] + public function testBug13473(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/bug-13473.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13473.php b/tests/PHPStan/Rules/Variables/data/bug-13473.php new file mode 100644 index 00000000000..11921e86540 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13473.php @@ -0,0 +1,22 @@ += 8.4 + +declare(strict_types = 1); + +namespace Bug13473; + +class Foo { + private(set) int $bar { + get => $this->bar; + set(int $bar) { + if (isset($this->bar)) { + throw new \Exception('bar is set'); + } + $this->bar = $bar; + } + } + + public function __construct(int $bar) + { + $this->bar = $bar; + } +} From 72cd601cc2069d33897e7b028badf9cda23201d8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 16:39:23 +0000 Subject: [PATCH 2/4] Fix isset() false positive for methods called from constructor When a method that assigns a property is called from the constructor, PHPStan merges the property initialization state back into the constructor scope. This caused a false positive when the same method used isset() to check if the property was already initialized, because the preserved constructor scope incorrectly marked the property as initialized when entering the method. The fix removes PropertyInitializationExpr entries for properties that a method itself initializes, when the method was called from the constructor. This generalizes the property hook fix to also work for regular methods. Also extracts exitPropertyInitialization() on MutatingScope to share the logic between property hooks and regular methods. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 13 ++++++++--- src/Analyser/NodeScopeResolver.php | 14 ++++++++++++ .../PHPStan/Rules/Variables/IssetRuleTest.php | 7 ++++++ .../Rules/Variables/data/bug-13473-method.php | 22 +++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13473-method.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index ad76e4ab10c..13490dcc0a2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1608,9 +1608,7 @@ public function enterPropertyHook( ); if ($hookName === 'set') { - $initExprKey = $this->getNodeKey(new PropertyInitializationExpr($propertyName)); - unset($scope->expressionTypes[$initExprKey]); - unset($scope->nativeExpressionTypes[$initExprKey]); + $scope->exitPropertyInitialization($propertyName); } return $scope; @@ -2840,6 +2838,15 @@ public function assignInitializedProperty(Type $fetchedOnType, string $propertyN return $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType()); } + public function exitPropertyInitialization(string $propertyName): self + { + $initExprKey = $this->getNodeKey(new PropertyInitializationExpr($propertyName)); + unset($this->expressionTypes[$initExprKey]); + unset($this->nativeExpressionTypes[$initExprKey]); + + return $this; + } + public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self { $expressionTypes = $this->expressionTypes; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f616b644ab3..21d9824e28f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -712,6 +712,20 @@ public function processStmtNode( $classReflection = $scope->getClassReflection(); + if (!$isConstructor && !$stmt->isStatic()) { + $stackName = sprintf('%s::%s', $classReflection->getName(), $stmt->name->toString()); + $calledMethodScope = $this->calledMethodResults[$stackName] ?? null; + if ($calledMethodScope !== null) { + foreach ($calledMethodScope->expressionTypes as $typeHolder) { + $expr = $typeHolder->getExpr(); + if (!$expr instanceof PropertyInitializationExpr) { + continue; + } + $methodScope = $methodScope->exitPropertyInitialization($expr->getPropertyName()); + } + } + } + if ($isConstructor) { foreach ($stmt->params as $param) { if ($param->flags === 0 && $param->hooks === []) { diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index f69d783c42f..726b3bc2c78 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -570,4 +570,11 @@ public function testBug13473(): void $this->analyse([__DIR__ . '/data/bug-13473.php'], []); } + public function testBug13473Method(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/bug-13473-method.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13473-method.php b/tests/PHPStan/Rules/Variables/data/bug-13473-method.php new file mode 100644 index 00000000000..36e05dbc344 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13473-method.php @@ -0,0 +1,22 @@ +setBar($bar); + } + + public function setBar(int $bar): void + { + if (isset($this->bar)) { + throw new \Exception('bar is set'); + } + $this->bar = $bar; + } +} From db70c6c27ab6da7aca1fd5e757860b559d4716d0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 18:46:44 +0000 Subject: [PATCH 3/4] Add tests proving isset() still reports for properties with default values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties with default values (e.g. `private int $bar = 1;`) are handled by `hasDefaultValue()` in IssetCheck, not by PropertyInitializationExpr. Removing PropertyInitializationExpr entries for methods called from the constructor does not affect properties with defaults — they are still correctly flagged. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Variables/IssetRuleTest.php | 14 ++++++++++++-- .../Rules/Variables/data/bug-13473-method.php | 17 +++++++++++++++++ .../PHPStan/Rules/Variables/data/bug-13473.php | 17 +++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 726b3bc2c78..6bc8a0fc0ff 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -567,14 +567,24 @@ public function testBug13473(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-13473.php'], []); + $this->analyse([__DIR__ . '/data/bug-13473.php'], [ + [ + 'Property Bug13473\FooWithDefault::$bar (int) in isset() is not nullable.', + 28, + ], + ]); } public function testBug13473Method(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-13473-method.php'], []); + $this->analyse([__DIR__ . '/data/bug-13473-method.php'], [ + [ + 'Property Bug13473Method\FooWithDefault::$bar (int) in isset() is not nullable.', + 34, + ], + ]); } } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13473-method.php b/tests/PHPStan/Rules/Variables/data/bug-13473-method.php index 36e05dbc344..893fc71a966 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-13473-method.php +++ b/tests/PHPStan/Rules/Variables/data/bug-13473-method.php @@ -20,3 +20,20 @@ public function setBar(int $bar): void $this->bar = $bar; } } + +class FooWithDefault { + private int $bar = 1; + + public function __construct(int $bar) + { + $this->setBar($bar); + } + + public function setBar(int $bar): void + { + if (isset($this->bar)) { + throw new \Exception('bar is set'); + } + $this->bar = $bar; + } +} diff --git a/tests/PHPStan/Rules/Variables/data/bug-13473.php b/tests/PHPStan/Rules/Variables/data/bug-13473.php index 11921e86540..cdafdff10fe 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-13473.php +++ b/tests/PHPStan/Rules/Variables/data/bug-13473.php @@ -20,3 +20,20 @@ public function __construct(int $bar) $this->bar = $bar; } } + +class FooWithDefault { + private(set) int $bar = 1 { + get => $this->bar; + set(int $bar) { + if (isset($this->bar)) { + throw new \Exception('bar is set'); + } + $this->bar = $bar; + } + } + + public function __construct(int $bar) + { + $this->bar = $bar; + } +} From b67256c345c83a4f59d74fef96e960468d0c5a8a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 11 Apr 2026 22:51:09 +0000 Subject: [PATCH 4/4] Preserve PropertyInitializationExpr for properties with default values When entering a set hook or a method called from the constructor, only remove PropertyInitializationExpr for properties without default values. Properties with defaults (e.g. `private int $bar = 1`) are always initialized, so isset() should report "not nullable nor uninitialized" rather than just "not nullable". Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 8 +++++++- src/Analyser/NodeScopeResolver.php | 9 ++++++++- tests/PHPStan/Rules/Variables/IssetRuleTest.php | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 13490dcc0a2..ec059692165 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1608,7 +1608,13 @@ public function enterPropertyHook( ); if ($hookName === 'set') { - $scope->exitPropertyInitialization($propertyName); + $classReflection = $this->getClassReflection(); + if ( + !$classReflection->hasNativeProperty($propertyName) + || !$classReflection->getNativeProperty($propertyName)->getNativeReflection()->hasDefaultValue() + ) { + $scope->exitPropertyInitialization($propertyName); + } } return $scope; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 21d9824e28f..25d46bdeaa2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -721,7 +721,14 @@ public function processStmtNode( if (!$expr instanceof PropertyInitializationExpr) { continue; } - $methodScope = $methodScope->exitPropertyInitialization($expr->getPropertyName()); + $propertyName = $expr->getPropertyName(); + if ( + $classReflection->hasNativeProperty($propertyName) + && $classReflection->getNativeProperty($propertyName)->getNativeReflection()->hasDefaultValue() + ) { + continue; + } + $methodScope = $methodScope->exitPropertyInitialization($propertyName); } } } diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 6bc8a0fc0ff..38aafa7016b 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -569,7 +569,7 @@ public function testBug13473(): void $this->analyse([__DIR__ . '/data/bug-13473.php'], [ [ - 'Property Bug13473\FooWithDefault::$bar (int) in isset() is not nullable.', + 'Property Bug13473\FooWithDefault::$bar in isset() is not nullable nor uninitialized.', 28, ], ]); @@ -581,7 +581,7 @@ public function testBug13473Method(): void $this->analyse([__DIR__ . '/data/bug-13473-method.php'], [ [ - 'Property Bug13473Method\FooWithDefault::$bar (int) in isset() is not nullable.', + 'Property Bug13473Method\FooWithDefault::$bar in isset() is not nullable nor uninitialized.', 34, ], ]);