🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Invalidate outer-scope expressions mutated by closures, arrow functions and callables passed as arguments - #5957

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-eyyrdk6
Jun 30, 2026
Merged

Invalidate outer-scope expressions mutated by closures, arrow functions and callables passed as arguments#5957
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-eyyrdk6

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan reported a false-positive foreach.emptyArray ("Empty array passed to foreach.") when a property narrowed to array{} (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 be array{} at the foreach.

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.php
    • Added shouldInvalidateCallbackExpressions(): 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.
    • Closure argument branch: invalidation is now gated on shouldInvalidateCallbackExpressions() instead of callCallbackImmediately(), and the invalidate expressions are taken from the closure's ClosureType (built with pending fibers flushed) rather than from ProcessClosureResult::getInvalidateExpressions().
    • ArrowFunction argument branch: now collects and defers invalidate expressions from the arrow function's ClosureType (it previously collected none).
    • Callable-value argument branch: invalidation is split out of the immediately-invoked throw/impure-point block and gated on shouldInvalidateCallbackExpressions().

Root cause

Two compounding problems:

  1. Invalidation was gated on "immediately invoked". For method calls whose callable parameter has no @param-immediately-invoked-callable annotation, callCallbackImmediately() returns false, so the closure's mutations were never applied to the outer scope. The correct condition is "invalidate unless explicitly later-invoked".
  2. ProcessClosureResult invalidate 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 after processArgs() has already read the (still-empty) invalidate list. The closure's ClosureType, in contrast, is resolved with processStmtNodes() (fibers flushed) and therefore carries the complete list. This second problem affected even immediately-invoked callbacks (e.g. a closure passed to array_map() mutating $this->prop[]).

Test

  • tests/PHPStan/Rules/Arrays/data/bug-14888.php + DeadForeachRuleTest::testBug14888() — the exact reproducer from the issue; asserts no foreach.emptyArray error. 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:
    • closure, arrow function and $callable-variable passed to a method, each mutating $this->cb[], all assert list<int> (were array{} before the fix);
    • @param-later-invoked-callable closure and arrow function assert the narrowing is preserved (null), confirming the later-invoked semantics are not broken.

Fixes phpstan/phpstan#14888

…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.
@ondrejmirtes
ondrejmirtes merged commit 48ec320 into phpstan:2.2.x Jun 30, 2026
669 of 670 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-eyyrdk6 branch June 30, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive foreach.emptyArray when property is mutated inside a closure passed to another object's method

2 participants