From 0ac8116e3c54f679392b57cdbbca134d19970d45 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Jul 2026 17:11:18 +0200 Subject: [PATCH 1/2] Treat a property with a default value as always set in isset resolution issetCheck() treated every native non-virtual property without a tracked expression type as possibly uninitialized. A property with a default value cannot be uninitialized, so consult hasDefaultValue() in the guard. This restores narrowing through ?? (the coalesce only builds the left-is-null scope for its right side when the left is surely set) and makes isset() on a defaulted non-nullable property infer true. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b --- src/Analyser/MutatingScope.php | 5 ++- .../nsrt/isset-coalesce-empty-type.php | 2 +- .../nsrt/isset-property-default-value.php | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/isset-property-default-value.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f8219658d15..8cc3329b835 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1160,7 +1160,10 @@ public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = n if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { if (!$this->hasExpressionType($expr)->yes()) { $nativeReflection = $propertyReflection->getNativeReflection(); - if ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) { + if ( + ($nativeReflection === null || !$nativeReflection->getNativeReflection()->hasDefaultValue()) + && ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) + ) { if ($expr instanceof Node\Expr\PropertyFetch) { return $this->issetCheckUndefined($expr->var); } diff --git a/tests/PHPStan/Analyser/nsrt/isset-coalesce-empty-type.php b/tests/PHPStan/Analyser/nsrt/isset-coalesce-empty-type.php index ad81a1e6ac1..caa54683c96 100644 --- a/tests/PHPStan/Analyser/nsrt/isset-coalesce-empty-type.php +++ b/tests/PHPStan/Analyser/nsrt/isset-coalesce-empty-type.php @@ -246,7 +246,7 @@ class FooNativeProp public int $canBeUninitialized; function doFoo(FooNativeProp $foo): void { - assertType('bool', isset($foo->hasDefaultValue)); + assertType('true', isset($foo->hasDefaultValue)); $foo->isAssignedBefore = 5; assertType('true', isset($foo->isAssignedBefore)); diff --git a/tests/PHPStan/Analyser/nsrt/isset-property-default-value.php b/tests/PHPStan/Analyser/nsrt/isset-property-default-value.php new file mode 100644 index 00000000000..0fc19bb8f6e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/isset-property-default-value.php @@ -0,0 +1,32 @@ +value === null && $b->value === null) { + throw new \LogicException(); + } + + assertType('int', $a->value ?? $b->value); +} + +function coalesceWithoutDefault(Holder $a, Holder $b): void +{ + if ($a->noDefault === null && $b->noDefault === null) { + throw new \LogicException(); + } + + assertType('int|null', $a->noDefault ?? $b->noDefault); +} From 96eb99e1e4cb45538f00551592466c13ed83294f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Jul 2026 17:19:04 +0200 Subject: [PATCH 2/2] Add regression test for #10786 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b --- tests/PHPStan/Analyser/nsrt/bug-10786.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10786.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-10786.php b/tests/PHPStan/Analyser/nsrt/bug-10786.php new file mode 100644 index 00000000000..4dc9333aba6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10786.php @@ -0,0 +1,23 @@ +value) && is_null($b->value)) { + throw new \Exception(); + } + + assertType('int', $a->value ?? $b->value); + + return $a->value ?? $b->value; + } +}