From f21a84eb632ac70d921bc3d14232f27c3b0371f7 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 10:54:42 +0200 Subject: [PATCH 1/4] Keep narrowed types after an unrolled foreach whose iterations all terminate When every unrolled iteration of a constant-array foreach ends in return or throw (for example a loop that returns for each enum case), no iteration reaches the back edge and the unrolled end scope was null. ForeachHandler then used the scope of the generic final pass, so the code after the loop saw the subject with its full type instead of never. The unrolled processing now uses the end scope of the terminating iteration when the loop has no other exit. This restores the 2.2 behaviour, which 003d60144 changed. --- src/Analyser/StmtHandler/ForeachHandler.php | 9 ++ .../nsrt/foreach-enum-exhaust-never.php | 90 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index dea3ecfb40b..1a8bd7997c4 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -714,6 +714,7 @@ private function tryProcessUnrolledConstantArrayForeach( $allBodyScopes = []; $allChainScopes = []; $allBreakScopes = []; + $terminatedEndScopes = []; $bodyContext = $context->enterUnrolledForeach($totalKeys); @@ -797,6 +798,7 @@ private function tryProcessUnrolledConstantArrayForeach( } // no later iteration runs, the loop is left only through its break statements + $terminatedEndScopes[] = $bodyResult->getScope(); $chainScope = null; break; } @@ -843,6 +845,13 @@ private function tryProcessUnrolledConstantArrayForeach( $endScope = $endScope === null ? $breakScope : $endScope->mergeWith($breakScope); } + if ($endScope === null) { + // the code after the loop is unreachable, keep the narrowings that made the last iteration terminate + foreach ($terminatedEndScopes as $terminatedEndScope) { + $endScope = $endScope === null ? $terminatedEndScope : $endScope->mergeWith($terminatedEndScope); + } + } + // Unsealed shapes describe zero-or-more additional entries beyond the // explicit keys. Run the scope-generalizing loop on top of the // unrolled explicit iterations so body-scope variables (e.g. counters) diff --git a/tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php b/tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php new file mode 100644 index 00000000000..3c974cd9878 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php @@ -0,0 +1,90 @@ += 8.1 + +declare(strict_types = 1); + +namespace ForeachEnumExhaustNever; + +use function PHPStan\Testing\assertType; + +enum Status +{ + case A; + case B; + case C; +} + +function allCases(Status $status): void +{ + foreach ([Status::A, Status::B, Status::C] as $s) { + if ($status === $s) { + return; + } + } + + assertType('*NEVER*', $status); +} + +function someCases(Status $status): void +{ + foreach ([Status::A, Status::B] as $s) { + if ($status === $s) { + return; + } + } + + assertType('ForeachEnumExhaustNever\Status::C', $status); +} + +function keyed(Status $status): int +{ + foreach ([1 => Status::A, 2 => Status::B, 3 => Status::C] as $value => $s) { + if ($status === $s) { + return $value; + } + } + + assertType('*NEVER*', $status); + + return 0; +} + +function inArray(Status $status): int +{ + foreach ([1 => [Status::A, Status::B], 2 => [Status::C]] as $value => $statuses) { + if (in_array($status, $statuses, true)) { + return $value; + } + } + + assertType('*NEVER*', $status); + + return 0; +} + +/** + * @param 1|2|3 $i + */ +function integers(int $i): void +{ + foreach ([1, 2, 3] as $s) { + if ($i === $s) { + return; + } + } + + assertType('*NEVER*', $i); +} + +function withBreak(Status $status): void +{ + foreach ([Status::A, Status::B, Status::C] as $s) { + if ($status === Status::B) { + break; + } + if ($status === $s) { + return; + } + } + + assertType('ForeachEnumExhaustNever\Status::B', $status); +} From edc7d199c3b87eb1ef173b4c745cc343b5ec29cb Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 11:05:17 +0200 Subject: [PATCH 2/4] Keep narrowed types after an unrolled foreach whose iterations all terminate in turbo-ext Port cfd7d138b to the native ForeachHandler. The native tryProcessUnrolledConstantArrayForeach() now collects the scope of each iteration that terminates. When the loop has no chain scope and no break scope, it merges these scopes into the end scope, in the same order as the PHP twin. --- turbo-ext/src/ForeachHandler.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/turbo-ext/src/ForeachHandler.cpp b/turbo-ext/src/ForeachHandler.cpp index 49ca2f9827f..643368371bb 100644 --- a/turbo-ext/src/ForeachHandler.cpp +++ b/turbo-ext/src/ForeachHandler.cpp @@ -1494,6 +1494,7 @@ class ForeachHandler zv::Arr allBodyScopes = zv::Arr::empty(); zv::Arr allChainScopes = zv::Arr::empty(); zv::Arr allBreakScopes = zv::Arr::empty(); + zv::Arr terminatedEndScopes = zv::Arr::empty(); zv::Val bodyContext = pt_statement_context_enter_unrolled_foreach(context, totalKeys); if (UNEXPECTED(bodyContext.isUndef())) return false; @@ -1626,6 +1627,12 @@ class ForeachHandler } // no later iteration runs, the loop is left only through its break statements + { + zv::Val hold; + zval *terminatedScope = pt_internal_statement_result_scope(bodyResult.raw(), hold); + if (UNEXPECTED(terminatedScope == NULL)) return false; + terminatedEndScopes.push(zv::Ref(terminatedScope)); + } chainScope = zv::Val::null(); break; } @@ -1683,6 +1690,13 @@ class ForeachHandler if (UNEXPECTED(!ptlh::mergeOrTake(endScope, entry.value().deref().raw()))) return false; } + if (endScope.isNull()) { + // the code after the loop is unreachable, keep the narrowings that made the last iteration terminate + for (auto entry : zv::ArrRef(terminatedEndScopes.raw())) { + if (UNEXPECTED(!ptlh::mergeOrTake(endScope, entry.value().deref().raw()))) return false; + } + } + // Unsealed shapes describe zero-or-more additional entries beyond the // explicit keys. Run the scope-generalizing loop on top of the // unrolled explicit iterations so body-scope variables (e.g. counters) From f6fead94c1aed76df06b509f37d5e9770e2196ac Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 11:05:21 +0200 Subject: [PATCH 3/4] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 88c83cac563..030b42a0049 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '28c29b8'; + public const EXPECTED_EXTENSION_VERSION = 'edc7d19'; private static bool $active = false; From fa4469a47344663275cbd486ae759e3eb3ce02a7 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 6 Oct 2026 12:00:49 +0200 Subject: [PATCH 4/4] Rename regression test to bug-15390 Fixes https://github.com/phpstan/phpstan/issues/15390 --- .../nsrt/{foreach-enum-exhaust-never.php => bug-15390.php} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename tests/PHPStan/Analyser/nsrt/{foreach-enum-exhaust-never.php => bug-15390.php} (89%) diff --git a/tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php b/tests/PHPStan/Analyser/nsrt/bug-15390.php similarity index 89% rename from tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php rename to tests/PHPStan/Analyser/nsrt/bug-15390.php index 3c974cd9878..013f7736515 100644 --- a/tests/PHPStan/Analyser/nsrt/foreach-enum-exhaust-never.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15390.php @@ -2,7 +2,7 @@ declare(strict_types = 1); -namespace ForeachEnumExhaustNever; +namespace Bug15390; use function PHPStan\Testing\assertType; @@ -32,7 +32,7 @@ function someCases(Status $status): void } } - assertType('ForeachEnumExhaustNever\Status::C', $status); + assertType('Bug15390\Status::C', $status); } function keyed(Status $status): int @@ -86,5 +86,5 @@ function withBreak(Status $status): void } } - assertType('ForeachEnumExhaustNever\Status::B', $status); + assertType('Bug15390\Status::B', $status); }