Skip to content

Enter right-side-assign context for ??= so closures capturing the assigned variable by reference see its assigned type#5895

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-q46f4vy
Open

Enter right-side-assign context for ??= so closures capturing the assigned variable by reference see its assigned type#5895
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-q46f4vy

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When a closure captured a variable by reference and that same variable was the target of a ??= assignment, e.g.

static $isSupported;
$isSupported ??= function (mixed $arg) use (&$isSupported): bool {
    return $isSupported($arg);
};

PHPStan inferred $isSupported as null inside the closure body and reported a false Trying to invoke null but it's not a callable. error. The exact same pattern written with a plain assignment (if (!isset($isSupported)) { $isSupported = function () use (&$isSupported) ... }) worked correctly. This change makes ??= behave like = for this case.

Changes

  • src/Analyser/ExprHandler/AssignOpHandler.php: when evaluating the right-hand side of an Expr\AssignOp\Coalesce (??=) whose target is a simple variable, the handler now calls ExpressionContext::enterRightSideAssign($var->name, $expr->expr), the same call AssignHandler already makes for =/=&.
  • tests/PHPStan/Analyser/nsrt/bug-13810.php: regression test asserting the closure type is inferred for a ??= recursive by-reference closure capture.

Root cause

The by-reference closure-use handling in NodeScopeResolver::processClosureExpr() reads inAssignRightSideVariableName / inAssignRightSideExpr from the current ExpressionContext. When the captured variable matches the variable currently being assigned, it resolves the variable to the assigned (closure) type inside the closure body, which is what makes $x = function () use (&$x) { ... } resolve $x to the closure.

Only AssignHandler (plain = / =&) populated that context. AssignOpHandler, which handles ??= together with the arithmetic/string compound operators, never called enterRightSideAssign(), so for ??= the context stayed empty and the by-reference variable fell back to its pre-assignment type (null after the ??= falsey filter). The fix populates the context for ??=, the only compound operator for which capturing-and-reassigning the same variable by reference is meaningful.

I probed the sibling assignment operators on the same axis: plain =/=& already set the context; the arithmetic and string compound ops (+=, -=, .=, ...) require the left-hand variable to already hold a non-closure value and cannot meaningfully reassign it to a captured closure, so they did not need the change.

The second example in the issue (a &$tmpdir capture whose value is reassigned in the enclosing scope after the closure is created) is a different, deeper limitation: PHPStan's forward flow analysis does not see writes performed through the reference later in the enclosing scope. That is not addressed here.

Test

  • tests/PHPStan/Analyser/nsrt/bug-13810.php asserts that inside $isSupported ??= function (mixed $arg) use (&$isSupported) { ... } the variable $isSupported is Closure(mixed): bool (previously null), and mixed~null in the enclosing scope after the ??=. Verified to fail before the fix (Actual: null) and pass after.

Fixes phpstan/phpstan#13810

…ssigned variable by reference see its assigned type

- `AssignOpHandler` now calls `ExpressionContext::enterRightSideAssign()` when
  evaluating the right-hand side of a `??=` whose target is a simple variable,
  mirroring what `AssignHandler` already does for plain `=`/`=&`.
- This lets the by-reference closure-use handling in `NodeScopeResolver`
  (which inspects `inAssignRightSideVariableName`/`inAssignRightSideExpr`)
  recognise patterns like `$cb ??= function () use (&$cb) { ... }` and resolve
  `$cb` to the assigned closure type inside the closure body instead of `null`.
- Without this, `??=` (unlike `=`) left the right-side-assign context empty, so a
  recursive `use (&$var)` closure saw `$var` as `null` and produced false
  `callable.nonCallable` errors.
- Probed the sibling axis (other assignment operators): plain `=`/`=&` already
  set this context; the arithmetic/string compound ops (`+=`, `.=`, ...) cannot
  meaningfully capture-and-reassign the same variable by reference, so only
  `??=` needed the change.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need a fix which also works for phpstan/phpstan#7751

phpstan-bot and others added 2 commits June 18, 2026 09:40
…ssignments

A variable captured by reference shares storage with the enclosing variable, and
the closure may be invoked at any later point. Forward flow analysis only sees the
variable's type at the point the closure is created, so reassignments performed in
the enclosing scope afterwards were invisible inside the closure body, producing
false "always true/false" impossible-type errors.

ClosureUseByRefAssignmentsVisitor collects, per closure, the right-hand side
expressions of assignments to each by-reference use variable that occur after the
closure definition in the enclosing function-like scope. processClosureExpr() then
unions those types into the variable's type seeding the closure body. Assignments
preceding the closure are already reflected in the variable's narrowed type and are
left untouched, preserving existing precision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fixture uses the mixed type, which requires a `// lint >= 8.0` comment so it
is skipped on older PHP versions in CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

The changes are complete, tested, and pushed. Let me summarize what I did to address the review.

Summary

The reviewer (staabm) requested that the fix also work for phpstan/phpstan#7751 — the more general case where a by-reference closure capture is reassigned in the enclosing scope after the closure is defined, which the original ??=-only PR didn't address. This is the same class of bug as the second example in #13810 (the &$tmpdir register-shutdown-function case).

Root cause

Forward-flow analysis only knows a captured variable's type at the point the closure is created. A variable captured by reference shares storage with the enclosing variable, and the closure can be invoked at any later time — so reassignments performed in the enclosing scope after the closure are invisible inside the closure body. PHPStan therefore inferred $foo as false (or $tmpdir as '') and emitted false is_array() will always evaluate to false / Result of && is always false errors.

Fix

  1. src/Parser/ClosureUseByRefAssignmentsVisitor.php (new) — for each closure, collects the RHS expressions of assignments to each by-reference use variable that occur after the closure definition in the enclosing function-like scope.
  2. src/Analyser/NodeScopeResolver.phpprocessClosureExpr() unions those later-assigned types into the variable's type seeding the closure body, via a new widenByRefUseFromEnclosingAssignments() helper.

The key insight that avoided a precision regression: only assignments after the closure are unioned. Assignments before the closure are already reflected in the variable's narrowed type, so unioning them would re-introduce stale/wider types (this initially broke PHPStan's own ClassReflection::getAncestors(), which uses a synchronously-invoked &$ancestors closure — caught by make phpstan).

Verification

  • New regression test tests/PHPStan/Analyser/nsrt/bug-7751.php covers both the is_array and the shutdown-function examples; confirmed to fail before the fix (Actual: false) and pass after.
  • The original ??= fix (bug-13810.php) and existing closure-passed-by-reference.php still pass.
  • Full make tests (17,417 tests) green; make phpstan clean.
  • Also added the required // lint >= 8.0 comment to bug-13810.php (it uses mixed), committed separately.

Both commits pushed to create-pull-request/patch-q46f4vy.

One thing worth flagging for the reviewer: vendor/attributes.php (the composer-attribute-collector cache that registers the new visitor) is git-ignored and regenerated on composer dump-autoload, so CI will pick up the new visitor automatically — no manual registration needed.

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.

References in use clauses are not processed correctly

2 participants