From 25046b7ff6ec868f96a042b7140d8392ce333145 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:32:04 +0000 Subject: [PATCH 1/9] Fix false positive "Empty array passed to foreach" in closure with object use - Don't carry forward property fetch expression types for dynamic (undeclared) properties on objects captured by value in closures - Objects are references in PHP, so their properties can change between closure definition and invocation - Only affects dynamic properties (e.g. stdClass); declared/native properties still carry forward type narrowings - New regression test in tests/PHPStan/Rules/Arrays/data/bug-10345.php Closes https://github.com/phpstan/phpstan/issues/10345 --- src/Analyser/MutatingScope.php | 26 +++++++++++++++++++ .../Rules/Arrays/DeadForeachRuleTest.php | 5 ++++ tests/PHPStan/Rules/Arrays/data/bug-10345.php | 15 +++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-10345.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f1dfb1f0da..60f2fc6f14 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2152,6 +2152,10 @@ public function enterAnonymousFunctionWithoutReflection( } } + if ($this->shouldNotCarryForwardPropertyFetchInClosure($expr)) { + continue; + } + $expressionTypes[$exprString] = $typeHolder; } @@ -2218,6 +2222,28 @@ public function enterAnonymousFunctionWithoutReflection( ); } + private function shouldNotCarryForwardPropertyFetchInClosure(Expr $expr): bool + { + if (!$expr instanceof PropertyFetch) { + return false; + } + + if (!$expr->name instanceof Identifier) { + return false; + } + + $objectType = $this->getType($expr->var); + $propertyName = $expr->name->name; + + foreach ($objectType->getObjectClassReflections() as $classReflection) { + if ($classReflection->hasNativeProperty($propertyName)) { + return false; + } + } + + return true; + } + private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool { $expr = $typeHolder->getExpr(); diff --git a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php index 9e272b5802..38d62a1b45 100644 --- a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php @@ -55,4 +55,9 @@ public function testBug2457(): void $this->analyse([__DIR__ . '/data/bug-2457.php'], []); } + public function testBug10345(): void + { + $this->analyse([__DIR__ . '/data/bug-10345.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php new file mode 100644 index 0000000000..e4a6829e58 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -0,0 +1,15 @@ +items = []; + +$func = function() use ($container): int { + foreach ($container->items as $item) {} + return 1; +}; + +$container->items[] = '1'; + +$a = $func(); From 667108949e229901027edc7bd43e3dc85d80a844 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 09:28:50 +0000 Subject: [PATCH 2/9] Do not carry forward any property fetch types into closure scope Objects are always references in PHP, even when captured by value via `use`. Properties (both dynamic and declared) can be modified between closure definition and invocation, so their types should not be frozen at closure definition time. Simplify shouldNotCarryForwardPropertyFetchInClosure to skip all PropertyFetch expressions, not just dynamic properties. Add test case with declared property class (Foo with list $items). Update specified-types-closure-use.php expectations accordingly. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 19 +------------------ .../nsrt/specified-types-closure-use.php | 4 ++-- tests/PHPStan/Rules/Arrays/data/bug-10345.php | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 60f2fc6f14..dcf6509456 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2224,24 +2224,7 @@ public function enterAnonymousFunctionWithoutReflection( private function shouldNotCarryForwardPropertyFetchInClosure(Expr $expr): bool { - if (!$expr instanceof PropertyFetch) { - return false; - } - - if (!$expr->name instanceof Identifier) { - return false; - } - - $objectType = $this->getType($expr->var); - $propertyName = $expr->name->name; - - foreach ($objectType->getObjectClassReflections() as $classReflection) { - if ($classReflection->hasNativeProperty($propertyName)) { - return false; - } - } - - return true; + return $expr instanceof PropertyFetch; } private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool diff --git a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php index 9cd49e4522..daf810ea91 100644 --- a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php +++ b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php @@ -13,7 +13,7 @@ public function doFoo(MethodCall $call, MethodCall $bar): void { if ($call->name instanceof Identifier && $bar->name instanceof Identifier) { function () use ($call): void { - assertType('PhpParser\Node\Identifier', $call->name); + assertType('PhpParser\Node\Expr|PhpParser\Node\Identifier', $call->name); assertType('mixed', $bar->name); }; @@ -26,7 +26,7 @@ public function doBar(MethodCall $call, MethodCall $bar): void if ($call->name instanceof Identifier && $bar->name instanceof Identifier) { $a = 1; function () use ($call, &$a): void { - assertType('PhpParser\Node\Identifier', $call->name); + assertType('PhpParser\Node\Expr|PhpParser\Node\Identifier', $call->name); assertType('mixed', $bar->name); }; diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php index e4a6829e58..4260c2fbe3 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-10345.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -13,3 +13,20 @@ $container->items[] = '1'; $a = $func(); + +class Foo { + /** @var list */ + public array $items = []; +} + +$container2 = new Foo(); +$container2->items = []; + +$func2 = function() use ($container2): int { + foreach ($container2->items as $item) {} + return 1; +}; + +$container2->items[] = '1'; + +$a2 = $func2(); From 89f6e93c7d3fcc4b61827f592ecf4b3a5636a481 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 09:39:58 +0000 Subject: [PATCH 3/9] Inline shouldNotCarryForwardPropertyFetchInClosure check The method was a trivial one-liner, so inline the instanceof check directly at the call site and remove the private method. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index dcf6509456..a5e7acb36c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2152,7 +2152,7 @@ public function enterAnonymousFunctionWithoutReflection( } } - if ($this->shouldNotCarryForwardPropertyFetchInClosure($expr)) { + if ($expr instanceof PropertyFetch) { continue; } @@ -2222,11 +2222,6 @@ public function enterAnonymousFunctionWithoutReflection( ); } - private function shouldNotCarryForwardPropertyFetchInClosure(Expr $expr): bool - { - return $expr instanceof PropertyFetch; - } - private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): bool { $expr = $typeHolder->getExpr(); From 7efb94b1dd601569385154b80961b4b244e1e0ca Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 09:50:34 +0000 Subject: [PATCH 4/9] Do not carry forward method call types into closure scope Objects are always references in PHP, even when captured by value via `use`. Method return values can change if object state changes between closure definition and invocation. Skip MethodCall expressions (in addition to PropertyFetch) when entering closure scope. Add regression test for method call narrowing with getter/setter pattern. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 +- tests/PHPStan/Rules/Arrays/data/bug-10345.php | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a5e7acb36c..1577fa9af3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2152,7 +2152,7 @@ public function enterAnonymousFunctionWithoutReflection( } } - if ($expr instanceof PropertyFetch) { + if ($expr instanceof PropertyFetch || $expr instanceof MethodCall) { continue; } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php index 4260c2fbe3..366e88f29a 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-10345.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -30,3 +30,32 @@ class Foo { $container2->items[] = '1'; $a2 = $func2(); + +class Bar { + /** @var list */ + private array $items = []; + + /** @return list */ + public function getItems(): array + { + return $this->items; + } + + /** @param list $items */ + public function setItems(array $items): void + { + $this->items = $items; + } +} + +$container3 = new Bar(); +if ($container3->getItems() === []) { + $func3 = function() use ($container3): int { + foreach ($container3->getItems() as $item) {} + return 1; + }; + + $container3->setItems(['foo']); + + $a3 = $func3(); +} From 0162284497072000c25c573f6f7a171f9f0b5798 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:10:28 +0000 Subject: [PATCH 5/9] Also exclude nullsafe and static access from closure scope forwarding Handle NullsafePropertyFetch, NullsafeMethodCall, StaticPropertyFetch, and StaticCall in addition to PropertyFetch and MethodCall when entering closure scope, since these can also be modified between closure definition and invocation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 9 ++- tests/PHPStan/Rules/Arrays/data/bug-10345.php | 55 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1577fa9af3..dc69755519 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2152,7 +2152,14 @@ public function enterAnonymousFunctionWithoutReflection( } } - if ($expr instanceof PropertyFetch || $expr instanceof MethodCall) { + if ( + $expr instanceof PropertyFetch + || $expr instanceof MethodCall + || $expr instanceof Expr\NullsafePropertyFetch + || $expr instanceof Expr\NullsafeMethodCall + || $expr instanceof Expr\StaticPropertyFetch + || $expr instanceof Expr\StaticCall + ) { continue; } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php index 366e88f29a..5173bc3f1d 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-10345.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -59,3 +59,58 @@ public function setItems(array $items): void $a3 = $func3(); } + +// Nullsafe property fetch +$container4 = new Foo(); +$container4->items = []; + +$func4 = function() use ($container4): int { + foreach ($container4?->items as $item) {} + return 1; +}; + +$container4->items[] = '1'; + +$a4 = $func4(); + +// Static property access +class Baz { + /** @var list */ + public static array $items = []; + + /** @return list */ + public static function getItems(): array + { + return self::$items; + } + + /** @param list $items */ + public static function setItems(array $items): void + { + self::$items = $items; + } +} + +Baz::$items = []; + +$func5 = function(): int { + foreach (Baz::$items as $item) {} + return 1; +}; + +Baz::$items[] = '1'; + +$a5 = $func5(); + +// Static method call +Baz::setItems([]); +if (Baz::getItems() === []) { + $func6 = function(): int { + foreach (Baz::getItems() as $item) {} + return 1; + }; + + Baz::setItems(['foo']); + + $a6 = $func6(); +} From 868c3dc6f6e7443f4c503347d9fbca577b890d32 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 11:20:31 +0100 Subject: [PATCH 6/9] Fix lint --- tests/PHPStan/Rules/Arrays/data/bug-10345.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php index 5173bc3f1d..521c703498 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-10345.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -1,4 +1,6 @@ -= 8.0 + +declare(strict_types = 1); namespace Bug10345; From 1a738cd96c08a5e0bd934bd8227c25ee59783377 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:38:24 +0000 Subject: [PATCH 7/9] Preserve property fetch types in immediately invoked closures For closures that are immediately invoked (IIFEs and array_map callbacks), property/method fetch type narrowings should still be carried forward since the object's properties cannot change between definition and invocation. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 17 ++++++++++------ .../nsrt/specified-types-closure-use.php | 20 +++++++++++++++++++ .../Rules/Arrays/DeadForeachRuleTest.php | 11 +++++++++- tests/PHPStan/Rules/Arrays/data/bug-10345.php | 18 +++++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index dc69755519..7436f4bd15 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -44,6 +44,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; use PHPStan\Parser\ArrayMapArgVisitor; +use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\Parser; use PHPStan\Php\PhpVersion; use PHPStan\Php\PhpVersionFactory; @@ -2153,12 +2154,16 @@ public function enterAnonymousFunctionWithoutReflection( } if ( - $expr instanceof PropertyFetch - || $expr instanceof MethodCall - || $expr instanceof Expr\NullsafePropertyFetch - || $expr instanceof Expr\NullsafeMethodCall - || $expr instanceof Expr\StaticPropertyFetch - || $expr instanceof Expr\StaticCall + $closure->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) !== true + && $closure->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) === null + && ( + $expr instanceof PropertyFetch + || $expr instanceof MethodCall + || $expr instanceof Expr\NullsafePropertyFetch + || $expr instanceof Expr\NullsafeMethodCall + || $expr instanceof Expr\StaticPropertyFetch + || $expr instanceof Expr\StaticCall + ) ) { continue; } diff --git a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php index daf810ea91..f2302cad3f 100644 --- a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php +++ b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php @@ -69,4 +69,24 @@ function ($key) use ($arr): void { } } + public function doIife(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // IIFE - property types should be carried forward + (function () use ($call): void { + assertType('PhpParser\Node\Identifier', $call->name); + })(); + } + } + + public function doArrayMap(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // array_map - closure is immediately invoked, property types should be carried forward + array_map(function () use ($call): void { + assertType('PhpParser\Node\Identifier', $call->name); + }, [1]); + } + } + } diff --git a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php index 38d62a1b45..473420c9a9 100644 --- a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php @@ -57,7 +57,16 @@ public function testBug2457(): void public function testBug10345(): void { - $this->analyse([__DIR__ . '/data/bug-10345.php'], []); + $this->analyse([__DIR__ . '/data/bug-10345.php'], [ + [ + 'Empty array passed to foreach.', + 125, + ], + [ + 'Empty array passed to foreach.', + 134, + ], + ]); } } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php index 521c703498..964d39fb5c 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-10345.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -116,3 +116,21 @@ public static function setItems(array $items): void $a6 = $func6(); } + +// Immediately invoked closure (IIFE) - should still detect empty array +$container7 = new \stdClass(); +$container7->items = []; + +$result7 = (function() use ($container7): int { + foreach ($container7->items as $item) {} + return 1; +})(); + +// array_map - closure is immediately invoked, should still detect empty array +$container8 = new \stdClass(); +$container8->items = []; + +$result8 = array_map(function() use ($container8): int { + foreach ($container8->items as $item) {} + return 1; +}, [1]); From 2e7e5ef025c36e412b436d2e8ec1faf4270d33a3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:54:56 +0000 Subject: [PATCH 8/9] Use callCallbackImmediately() instead of hardcoded ArrayMapArgVisitor check Instead of hardcoding ArrayMapArgVisitor in MutatingScope to determine if a closure is immediately invoked, set the ImmediatelyInvokedClosureVisitor attribute in NodeScopeResolver based on callCallbackImmediately(). This relies on PHPStan's existing mechanism for determining immediate vs later invocation, which respects @param-immediately-invoked-callable and @param-later-invoked-callable PHPDoc tags, and defaults to immediately invoked for function calls and later invoked for method calls. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 1 - src/Analyser/NodeScopeResolver.php | 14 ++++++-- .../nsrt/specified-types-closure-use.php | 27 +++++++++++++++ .../Rules/Arrays/DeadForeachRuleTest.php | 4 +++ tests/PHPStan/Rules/Arrays/data/bug-10345.php | 34 +++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7436f4bd15..7fb5ee14d5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2155,7 +2155,6 @@ public function enterAnonymousFunctionWithoutReflection( if ( $closure->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) !== true - && $closure->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) === null && ( $expr instanceof PropertyFetch || $expr instanceof MethodCall diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d6c6832f85..7886d4088e 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3406,9 +3406,14 @@ public function processArgs( } } + $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); + if ($callCallbackImmediately) { + $arg->value->setAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME, true); + } + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + if ($callCallbackImmediately) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $closureResult->isAlwaysTerminating(); @@ -3464,9 +3469,14 @@ public function processArgs( } } + $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); + if ($callCallbackImmediately) { + $arg->value->setAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME, true); + } + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + if ($callCallbackImmediately) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $arrowFunctionResult->isAlwaysTerminating(); diff --git a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php index f2302cad3f..6df9945f93 100644 --- a/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php +++ b/tests/PHPStan/Analyser/nsrt/specified-types-closure-use.php @@ -89,4 +89,31 @@ public function doArrayMap(MethodCall $call): void } } + public function doGenericFunctionCall(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // Generic function with callable parameter - immediately invoked by default + usort([1], function () use ($call): int { + assertType('PhpParser\Node\Identifier', $call->name); + return 0; + }); + } + } + + public function doLaterInvokedCallable(MethodCall $call): void + { + if ($call->name instanceof Identifier) { + // @param-later-invoked-callable - property types should NOT be carried forward + laterInvoke(function () use ($call): void { + assertType('PhpParser\Node\Expr|PhpParser\Node\Identifier', $call->name); + }); + } + } + +} + +/** + * @param-later-invoked-callable $callback + */ +function laterInvoke(callable $callback): void { } diff --git a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php index 473420c9a9..4767783a60 100644 --- a/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DeadForeachRuleTest.php @@ -66,6 +66,10 @@ public function testBug10345(): void 'Empty array passed to foreach.', 134, ], + [ + 'Empty array passed to foreach.', + 152, + ], ]); } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-10345.php b/tests/PHPStan/Rules/Arrays/data/bug-10345.php index 964d39fb5c..c71f426e9e 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-10345.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-10345.php @@ -134,3 +134,37 @@ public static function setItems(array $items): void foreach ($container8->items as $item) {} return 1; }, [1]); + +// Generic function with callable parameter - immediately invoked by default +/** + * @template T + * @param callable(): T $callback + * @return T + */ +function invoke(callable $callback): mixed { + return $callback(); +} + +$container9 = new \stdClass(); +$container9->items = []; + +$result9 = invoke(function() use ($container9): int { + foreach ($container9->items as $item) {} // should report error - immediately invoked + return 1; +}); + +// Function with @param-later-invoked-callable - should NOT report error +/** + * @param-later-invoked-callable $callback + */ +function invokeLater(callable $callback): void { + // stores callback for later +} + +$container10 = new \stdClass(); +$container10->items = []; + +invokeLater(function() use ($container10): int { + foreach ($container10->items as $item) {} // should NOT report error - later invoked + return 1; +}); From 787ffe1a25333d8ce9190c4dbba7f33b46187b0e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 11:17:03 +0000 Subject: [PATCH 9/9] Use separate attribute for callCallbackImmediately to avoid breaking by-ref closure loop The previous commit reused ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME for callbacks that are invoked immediately (e.g. array_map). This caused a regression because NodeScopeResolver::processClosureNode uses that same attribute to break the do-while loop after one iteration (correct for IIFEs called once, but wrong for array_map callbacks called multiple times with by-ref variables like &$isVariadic). Introduces a new CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME constant to distinguish "callback invoked immediately by a function" from "IIFE called exactly once". Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 ++ src/Analyser/NodeScopeResolver.php | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7fb5ee14d5..662bcb9019 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -146,6 +146,7 @@ class MutatingScope implements Scope, NodeCallbackInvoker { public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; + public const CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME = 'callCallbackImmediately'; private const CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME = 'containsSuperGlobal'; /** @var Type[] */ @@ -2155,6 +2156,7 @@ public function enterAnonymousFunctionWithoutReflection( if ( $closure->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) !== true + && $closure->getAttribute(self::CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME) !== true && ( $expr instanceof PropertyFetch || $expr instanceof MethodCall diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7886d4088e..25f9683411 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3408,7 +3408,7 @@ public function processArgs( $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); if ($callCallbackImmediately) { - $arg->value->setAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME, true); + $arg->value->setAttribute(MutatingScope::CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME, true); } $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); @@ -3471,7 +3471,7 @@ public function processArgs( $callCallbackImmediately = $this->callCallbackImmediately($parameter, $parameterType, $calleeReflection); if ($callCallbackImmediately) { - $arg->value->setAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME, true); + $arg->value->setAttribute(MutatingScope::CALL_CALLBACK_IMMEDIATELY_ATTRIBUTE_NAME, true); } $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context);