Invalidate outer-scope expressions mutated by closures, arrow functions and callables passed as arguments - #5957
Merged
ondrejmirtes merged 1 commit intoJun 30, 2026
Conversation
…ns and callables passed as arguments - `NodeScopeResolver::processArgs()` now invalidates the expressions a passed callback mutates whenever the callback may run, gated by the new `shouldInvalidateCallbackExpressions()` helper (invalidate unless the parameter is explicitly `@param-later-invoked-callable`), instead of only when the callback is treated as immediately invoked. This fixes the false-positive `foreach.emptyArray` when a property is mutated inside a closure passed to another object's method. - For directly-passed `Closure` arguments, read the invalidate expressions from the closure's `ClosureType` (collected with pending fibers flushed) instead of `ProcessClosureResult::getInvalidateExpressions()`. The latter is built via the fiber node-callback and is still incomplete when `processArgs()` reads it, so array-append writes like `$this->prop[] = ...` were missed - even for immediately-invoked callbacks such as `array_map()`. - Added the same invalidation to the arrow-function argument branch, which previously collected no invalidate expressions at all. - Split invalidation from throw/impure-point propagation in the callable-value argument branch so a `$callable` variable passed to a method also invalidates its mutations.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PHPStan reported a false-positive
foreach.emptyArray("Empty array passed to foreach.") when a property narrowed toarray{}(via$this->prop = []) was mutated inside a closure passed to a method on a different object, and iterated afterwards. The mutation that happens inside the escaping closure was never reflected back into the outer scope, so the property was still believed to bearray{}at theforeach.This change makes a callback passed as an argument invalidate the outer-scope expressions it mutates whenever the callback may run, and makes that invalidation cover array-append writes for all callback forms (closures, arrow functions and callable values).
Changes
src/Analyser/NodeScopeResolver.phpshouldInvalidateCallbackExpressions(): a callback passed as an argument escapes and may be invoked, so its mutations must invalidate the outer scope — unless the parameter is explicitly@param-later-invoked-callable, in which case the callback only runs after the current function returns.Closureargument branch: invalidation is now gated onshouldInvalidateCallbackExpressions()instead ofcallCallbackImmediately(), and the invalidate expressions are taken from the closure'sClosureType(built with pending fibers flushed) rather than fromProcessClosureResult::getInvalidateExpressions().ArrowFunctionargument branch: now collects and defers invalidate expressions from the arrow function'sClosureType(it previously collected none).shouldInvalidateCallbackExpressions().Root cause
Two compounding problems:
@param-immediately-invoked-callableannotation,callCallbackImmediately()returnsfalse, so the closure's mutations were never applied to the outer scope. The correct condition is "invalidate unless explicitly later-invoked".ProcessClosureResultinvalidate expressions are incomplete for array writes. They are collected through the fiber-based node callback; an assignment like$this->prop[] = ...is emitted from a parked fiber that only resumes afterprocessArgs()has already read the (still-empty) invalidate list. The closure'sClosureType, in contrast, is resolved withprocessStmtNodes()(fibers flushed) and therefore carries the complete list. This second problem affected even immediately-invoked callbacks (e.g. a closure passed toarray_map()mutating$this->prop[]).Test
tests/PHPStan/Rules/Arrays/data/bug-14888.php+DeadForeachRuleTest::testBug14888()— the exact reproducer from the issue; asserts noforeach.emptyArrayerror. Fails (reports the false positive at line 30) without the fix.tests/PHPStan/Analyser/nsrt/bug-14888.php— type-inference assertions covering the analogous callback forms found in Step 4:$callable-variable passed to a method, each mutating$this->cb[], all assertlist<int>(werearray{}before the fix);@param-later-invoked-callableclosure and arrow function assert the narrowing is preserved (null), confirming the later-invoked semantics are not broken.Fixes phpstan/phpstan#14888