Skip to content

Keep the terminated iterations' scope after an unrolled constant-array foreach when no iteration leaves the loop - #6810

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-6s60t7w
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-6s60t7w

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Take a foreach over a constant array where every iteration returns or throws, for example foreach ([Status::A, Status::B, Status::C] as $s) { if ($status === $s) return; }. Since 2.3.0, PHPStan no longer narrows the subject to never after such a loop. This PR restores the 2.2.x behaviour.

Changes

  • src/Analyser/StmtHandler/ForeachHandler.php: tryProcessUnrolledConstantArrayForeach() now records the scope of each non-optional iteration that ends the unrolled chain because its body never reaches the next iteration. If no chain end scope and no break scope exist, it merges these scopes and uses them as the loop's end scope.
  • turbo-ext/src/ForeachHandler.cpp: the same change in the native mirror. I checked that the regression test fails with the extension loaded and without the C++ change.

Root cause

Commit 003d601 ("Build a loop's back edge from its continue statements when the body end is unreachable") made the unrolled path set the chain scope to null once an iteration always terminates. With no break, the unrolled end scope then stayed null, and ForeachHandler used the generic final pass's scope instead. That pass binds the loop variable to the union of all values (Status::A|Status::B|Status::C), so $status was not narrowed. Before the commit, the unrolled path used the last iteration's end scope, where $status is never. The fix keeps that scope, but only as a fallback when the code after the loop is unreachable. It does not affect the possibly-undefined-variable fix from #14418, which still drops dead scopes whenever a real exit exists.

Analogous constructs I probed: while, for and do-while are never unrolled. The generic foreach path already falls back to the body result scope ($finalScope ??= $finalScopeResult->getScope()). The case where the terminating iteration ends in a bare return after continue already behaved the same way before the regression, because there the body's dead scope is not narrowed to never. So only the unrolled path needed the fix.

Test

tests/PHPStan/Analyser/nsrt/bug-15390.php covers:

  • both snippets from the issue: an identity comparison against enum cases, and in_array() against nested constant arrays with a key variable
  • an iteration that ends with throw and a constant integer union
  • a control case where not every value is covered, so Status::C must remain after the loop

Fixes phpstan/phpstan#15390

🤖 Generated with Claude Code

…y `foreach` when no iteration leaves the loop

- ForeachHandler::tryProcessUnrolledConstantArrayForeach() collects the
  end scope of every non-optional iteration whose body never reaches the
  next iteration. When no chain scope and no break scope remain (the code
  after the loop is unreachable), these scopes become the end scope.
  Previously the end scope stayed null, and the caller fell back to the
  generic loop pass. That pass sees the loop variable as the union of all
  values, so the subject lost its narrowing to `never`. This was a
  regression from 003d601.
- Ported the same change to the turbo-ext mirror (ForeachHandler.cpp).
- Analogous constructs: while/for/do-while loops are not unrolled, and
  the generic foreach path already falls back to the body result scope
  (`$finalScope ??= $finalScopeResult->getScope()`), so only the unrolled
  path was affected.
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-6s60t7w branch from 1105b2d to f2e4370 Compare October 27, 2026 18:45
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.

Type is not narrowed to never after a foreach over a constant array that returns for every value (regression in 2.3.0)

1 participant