From 56b4dd597156ae37c749715fade1fec3f553c972 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:09:07 +0000 Subject: [PATCH 1/5] Narrow the right side of `??=` with falsey `isset()` instead of `!== null` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AssignOpHandler::processExpr()` built the scope for the right-hand side of `??=` with `filterByFalseyValue(new NotIdentical($var, null))`, i.e. it asserted `$var === null`. That is only one half of the `??=` condition: the right side also runs when `$var` is not set at all. - Asserting `$var === null` on a target that can never hold `null` collapsed the whole containing type to `never`, so anything else read on the right side was analysed on a `*NEVER*` value (e.g. `Offset 'bar' on *NEVER* ...`) or reported as unnecessary. - Replaced the condition with `filterByFalseyValue(new Isset_([$var]))`, which is exactly the `??=` short-circuit condition and matches what `CoalesceHandler::resolveType()` already uses for `??`. - The single change fixes the whole family of left-hand-side forms, each covered by a new test: optional array offsets (`array{foo?: string}`), non-constant arrays with a constant key (`array`, previously `non-empty-array&hasOffsetValue('foo', *NEVER*)`), nested offsets, non-nullable instance properties, non-nullable static properties, array offsets on a property, and undefined variables (`$undefined ??= $undefined ?? 1` reported "always exists and is always null" instead of "is never defined"). - Nullable targets keep the old, more precise narrowing to `null`, except for `array` where the value is now `T|null` on the right side — consistent with what plain `??` already infers there. - Probed the sibling constructs and left them unchanged: `CoalesceHandler` (`??`), `IssetHandler`, `EmptyHandler` and the nullsafe handlers already use the correct condition; the nullsafe handlers' `!== null` truthy filter is the right semantics for `?->`. --- tests/PHPStan/Analyser/nsrt/bug-15021.php | 69 +++++++++++++++++++ .../Rules/Variables/NullCoalesceRuleTest.php | 35 ++++++++++ .../Rules/Variables/data/bug-15021.php | 8 +++ .../null-coalesce-assign-right-side-scope.php | 47 +++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15021.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-15021.php create mode 100644 tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-15021.php b/tests/PHPStan/Analyser/nsrt/bug-15021.php new file mode 100644 index 0000000000..f5ae99e03f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15021.php @@ -0,0 +1,69 @@ + $data */ +function nonConstantArray(array $data, string $key): void +{ + $data[$key] ??= assertType('array', $data); +} + +/** @param array $data */ +function nonConstantArrayConstantKey(array $data): void +{ + $data['foo'] ??= assertType('array', $data); +} + +/** @param array $data */ +function nonConstantArrayNullableValue(array $data): void +{ + $data['foo'] ??= assertType('string|null', $data['foo']); +} + +/** @param array{a?: array{b?: string, c?: string}} $data */ +function nestedOptionalOffset(array $data): void +{ + $data['a']['b'] ??= assertType('array{a?: array{b?: string, c?: string}}', $data); +} + +class Foo +{ + + public string $nonNullable = ''; + + public static string $staticNonNullable = ''; + + /** @var array{foo?: string, bar?: string} */ + public array $data = []; + +} + +function nonNullableProperty(Foo $foo): void +{ + $foo->nonNullable ??= assertType('string', $foo->nonNullable); +} + +function nonNullableStaticProperty(): void +{ + Foo::$staticNonNullable ??= assertType('string', Foo::$staticNonNullable); +} + +function propertyOffset(Foo $foo): void +{ + $foo->data['foo'] ??= assertType('array{foo?: string, bar?: string}', $foo->data); +} diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 17d1dd8594..a1a2de24cf 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -558,4 +558,39 @@ public function testBug14393(): void ]); } + public function testBug15021(): void + { + $this->analyse([__DIR__ . '/data/bug-15021.php'], []); + } + + public function testNullCoalesceAssignRightSideScope(): void + { + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], [ + [ + 'Property NullCoalesceAssignRightSideScope\Foo::$nonNullable (string) on left side of ??= is not nullable.', + 19, + ], + [ + 'Property NullCoalesceAssignRightSideScope\Foo::$nonNullable (string) on left side of ?? is not nullable.', + 19, + ], + [ + 'Static property NullCoalesceAssignRightSideScope\Foo::$staticNonNullable (string) on left side of ??= is not nullable.', + 24, + ], + [ + 'Static property NullCoalesceAssignRightSideScope\Foo::$staticNonNullable (string) on left side of ?? is not nullable.', + 24, + ], + [ + 'Variable $undefined on left side of ??= is never defined.', + 46, + ], + [ + 'Variable $undefined on left side of ?? is never defined.', + 46, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-15021.php b/tests/PHPStan/Rules/Variables/data/bug-15021.php new file mode 100644 index 0000000000..9d23d7734a --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-15021.php @@ -0,0 +1,8 @@ +nonNullable ??= $foo->nonNullable ?? null; +} + +function staticProperty(): void +{ + Foo::$staticNonNullable ??= Foo::$staticNonNullable ?? null; +} + +function propertyOffset(Foo $foo): void +{ + $foo->data['foo'] ??= $foo->data['bar'] ?? null; +} + +/** @param array{a?: array{b?: string, c?: string}} $data */ +function nestedOffset(array $data): void +{ + $data['a']['b'] ??= $data['a']['c'] ?? null; +} + +/** @param array $data */ +function nonConstantArray(array $data, string $key): void +{ + $data[$key] ??= $data['fallback'] ?? null; +} + +function undefinedVariable(): void +{ + $undefined ??= $undefined ?? 1; +} From 4fdaaad87facfef6d442869550b7934bfc65183d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Jul 2026 08:23:48 +0000 Subject: [PATCH 2/5] Cover `ArrayAccess`/`ArrayObject` offsets on the left side of `??=` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `??=` on an `ArrayAccess` offset was also affected by the old `$var === null` filter, but differently from an array target: the container object survives (the offset is `offsetGet()`-backed, so narrowing it does not collapse the object), while the offset expression itself was narrowed to `null`. - Added `nsrt/bug-15021.php` assertions for `ArrayAccess`, `ArrayAccess` and `ArrayObject`. Reading the same offset on the right side gave `null` before the change and gives `string|null` now; each case is paired with its plain `??` counterpart, which already inferred `string|null`, so the two constructs are asserted to agree. - Reading a *different* offset was never affected — `nonNullableOffsetAccessDifferentOffset()` pins that as the boundary. - Added the same forms to `null-coalesce-assign-right-side-scope.php`. They report nothing before or after, because `IssetCheck` deliberately does not report offsets on `ArrayAccess` (`offsetExists()` is not statically knowable); the cases guard against a future bogus report. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-15021.php | 42 +++++++++++++++++++ .../null-coalesce-assign-right-side-scope.php | 24 +++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15021.php b/tests/PHPStan/Analyser/nsrt/bug-15021.php index f5ae99e03f..e53aaff5e5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15021.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15021.php @@ -67,3 +67,45 @@ function propertyOffset(Foo $foo): void { $foo->data['foo'] ??= assertType('array{foo?: string, bar?: string}', $foo->data); } + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= assertType('string|null', $a['foo']); +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccessCoalesce(\ArrayAccess $a): void +{ + $x = $a['foo'] ?? assertType('string|null', $a['foo']); +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccessDifferentOffset(\ArrayAccess $a): void +{ + $a['foo'] ??= assertType('string|null', $a['bar']); +} + +/** @param \ArrayAccess $a */ +function nullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= assertType('string|null', $a['foo']); +} + +/** @param \ArrayAccess $a */ +function nullableOffsetAccessCoalesce(\ArrayAccess $a): void +{ + $x = $a['foo'] ?? assertType('string|null', $a['foo']); +} + +/** @param \ArrayObject $data */ +function arrayObjectOffset(\ArrayObject $data): void +{ + $data['foo'] ??= assertType('string|null', $data['foo']); +} + +/** @param \ArrayObject $data */ +function arrayObjectOffsetCoalesce(\ArrayObject $data): void +{ + $x = $data['foo'] ?? assertType('string|null', $data['foo']); +} diff --git a/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php b/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php index e4bb4267cb..55c5bf18d2 100644 --- a/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php +++ b/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php @@ -45,3 +45,27 @@ function undefinedVariable(): void { $undefined ??= $undefined ?? 1; } + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= $a['foo'] ?? null; +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccessDifferentOffset(\ArrayAccess $a): void +{ + $a['foo'] ??= $a['bar'] ?? null; +} + +/** @param \ArrayAccess $a */ +function nullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= $a['foo'] ?? null; +} + +/** @param \ArrayObject $data */ +function arrayObjectOffset(\ArrayObject $data): void +{ + $data['foo'] ??= $data['foo'] ?? null; +} From 47015fcbe0bb857a90d10b78c9655697383b01ba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Jul 2026 08:29:56 +0000 Subject: [PATCH 3/5] Cover `isset()`, `empty()` and `unset()` around `??=` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers whether the `filterByFalseyValue(Isset_)` change reaches the other set-ness constructs. It does, in three of the six probed combinations — always through the right-hand-side scope, never through the constructs' own narrowing, which shares no code with `AssignOpHandler`. Affected, each with a failing-before test: - `isset()`/`empty()` *read on the right side* — `$data['foo'] ??= isset($data['bar']) ? $data['bar'] : 'fallback'` analysed `$data['bar']` as `*ERROR*` (offset on `*NEVER*`), and `IssetRule` reported the bogus `Offset 'bar' on *NEVER* in isset() always exists and is not nullable.` - `unset()`/`empty()` *applied to the target before the `??=`* — after `unset($data['foo'])` the right side saw `*NEVER*` instead of `array{bar?: string}`, plus a bogus `Offset 'bar' on *NEVER*` from `NullCoalesceRule`; same for the `if (empty($data['foo']))` guard. - the whole `??=` *nested inside* `empty(...)`, `isset(...)` or an `unset()` offset expression — right side was `*NEVER*` in all three. Not affected, pinned as boundary cases: - the *resulting* type of the target after `??=` — `empty($data['foo'] ??= …)`, `isset($data['foo'] ??= …)` and `unset($other[$data['foo'] ??= …])` infer the same before and after. - `EmptyRule` and `UnsetRule` report nothing on the fixture either way; their test methods guard against a future bogus report. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/bug-15021.php | 74 +++++++++++++++++++ .../PHPStan/Rules/Variables/EmptyRuleTest.php | 7 ++ .../PHPStan/Rules/Variables/IssetRuleTest.php | 7 ++ .../Rules/Variables/NullCoalesceRuleTest.php | 4 + .../PHPStan/Rules/Variables/UnsetRuleTest.php | 5 ++ .../null-coalesce-assign-right-side-scope.php | 42 +++++++++++ 6 files changed, 139 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15021.php b/tests/PHPStan/Analyser/nsrt/bug-15021.php index e53aaff5e5..7864d1c5d2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15021.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15021.php @@ -109,3 +109,77 @@ function arrayObjectOffsetCoalesce(\ArrayObject $data): void { $x = $data['foo'] ?? assertType('string|null', $data['foo']); } + +/** @param array{foo?: string, bar?: string} $data */ +function issetOnRightSide(array $data): void +{ + $data['foo'] ??= isset($data['bar']) ? assertType('string', $data['bar']) : 'fallback'; +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyOnRightSide(array $data): void +{ + $data['foo'] ??= empty($data['bar']) ? 'fallback' : assertType('non-falsy-string', $data['bar']); +} + +/** @param array{foo?: string, bar?: string} $data */ +function unsetTargetBeforeAssignOp(array $data): void +{ + unset($data['foo']); + $data['foo'] ??= assertType('array{bar?: string}', $data); +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyTargetBeforeAssignOp(array $data): void +{ + if (empty($data['foo'])) { + $data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data); + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideEmpty(array $data): void +{ + if (empty($data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data))) { + echo 'empty'; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideIsset(array $data): void +{ + if (isset($data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data))) { + echo 'isset'; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideUnsetOffset(array $data): void +{ + $other = ['x' => 1, 'fallback' => 2]; + unset($other[$data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data)]); +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpResultInsideEmpty(array $data): void +{ + if (empty($data['foo'] ??= $data['bar'] ?? 'fallback')) { + assertType("array{foo: ''|'0', bar?: string}", $data); + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpResultInsideIsset(array $data): void +{ + if (isset($data['foo'] ??= $data['bar'] ?? null)) { + assertType('array{foo: string, bar?: string}', $data); + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function unsetAssignOpResultOffset(array $data): void +{ + $other = ['x' => 1, 'fallback' => 2]; + unset($other[$data['foo'] ??= $data['bar'] ?? 'fallback']); + assertType('array{x?: 1, fallback?: 2}', $other); +} diff --git a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php index 079b032e8e..6ab498be10 100644 --- a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php +++ b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php @@ -250,4 +250,11 @@ public function testIssetAfterRememberedConstructor(): void ]); } + public function testNullCoalesceAssignRightSideScope(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index af8f1542e0..db77f2aef5 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -610,4 +610,11 @@ public function testBug14610(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14610.php'], []); } + public function testNullCoalesceAssignRightSideScope(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index a1a2de24cf..4c39265508 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -590,6 +590,10 @@ public function testNullCoalesceAssignRightSideScope(): void 'Variable $undefined on left side of ?? is never defined.', 46, ], + [ + 'Offset \'foo\' on array{bar?: string} on left side of ??= does not exist.', + 89, + ], ]); } diff --git a/tests/PHPStan/Rules/Variables/UnsetRuleTest.php b/tests/PHPStan/Rules/Variables/UnsetRuleTest.php index 3bef8ab900..73515e60d5 100644 --- a/tests/PHPStan/Rules/Variables/UnsetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/UnsetRuleTest.php @@ -224,4 +224,9 @@ public function testUnsetHookedProperty(): void ]); } + public function testNullCoalesceAssignRightSideScope(): void + { + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php b/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php index 55c5bf18d2..9f4d6b214f 100644 --- a/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php +++ b/tests/PHPStan/Rules/Variables/data/null-coalesce-assign-right-side-scope.php @@ -69,3 +69,45 @@ function arrayObjectOffset(\ArrayObject $data): void { $data['foo'] ??= $data['foo'] ?? null; } + +/** @param array{foo?: string, bar?: string} $data */ +function issetOnRightSide(array $data): void +{ + $data['foo'] ??= isset($data['bar']) ? $data['bar'] : 'fallback'; +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyOnRightSide(array $data): void +{ + $data['foo'] ??= empty($data['bar']) ? 'fallback' : $data['bar']; +} + +/** @param array{foo?: string, bar?: string} $data */ +function unsetTargetBeforeAssignOp(array $data): void +{ + unset($data['foo']); + $data['foo'] ??= $data['bar'] ?? null; +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyTargetBeforeAssignOp(array $data): void +{ + if (empty($data['foo'])) { + $data['foo'] ??= $data['bar'] ?? null; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideEmpty(array $data): void +{ + if (empty($data['foo'] ??= $data['bar'] ?? null)) { + echo 'empty'; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideUnsetOffset(array $data): void +{ + $other = ['x' => 1, 'fallback' => 2]; + unset($other[$data['foo'] ??= $data['bar'] ?? 'fallback']); +} From 9c5947639a7416c27316e15015d8f0552f79828d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 30 Jul 2026 11:31:28 +0200 Subject: [PATCH 4/5] Update AssignOpHandler.php --- src/Analyser/ExprHandler/AssignOpHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index ad3c1c67af..efcf9df1f0 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -77,7 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $valueContext = $context; if ($expr instanceof Expr\AssignOp\Coalesce) { $valueScope = $valueScope->filterByFalseyValue( - new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), + new Expr\Isset_([$expr->var]), ); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { From a0a61b70de99f223dd40c6cdffa15f0e66296dcd Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 30 Jul 2026 11:32:50 +0200 Subject: [PATCH 5/5] cs --- src/Analyser/ExprHandler/AssignOpHandler.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index efcf9df1f0..03669ecc0c 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -6,8 +6,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; use PhpParser\Node\Expr\BinaryOp; -use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\AssignTargetWalkMode; use PHPStan\Analyser\ExpressionContext;