Fix phpstan/phpstan#10345: Array inside object used in anonymous function assumed to be empty ("Empty array passed to foreach")#5196
Conversation
…ject 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 phpstan/phpstan#10345
d1de9eb to
25046b7
Compare
|
Testing something. Are you going to see this in a review? |
ondrejmirtes
left a comment
There was a problem hiding this comment.
Hey, this is a review. Let me know if you see this.
|
Looking at the review, it appears ondrejmirtes is testing the review notification system rather than requesting actual code changes. The review body says:
And the earlier comment says:
There are no specific code changes requested in this review. The reviewer seems to be verifying that the PR automation (likely this bot) can detect and respond to reviews. No code changes are needed since no actionable feedback was provided. If there are actual code review comments or change requests to address, please share them and I'll be happy to implement the changes. |
VincentLanglet
left a comment
There was a problem hiding this comment.
It should work with declared property too, so add this test
class Foo {
/** @var list<string> */
public array $items = [];
}
$container = new Foo();
$container->items = [];
$func = function() use ($container): int {
foreach ($container->items as $item) {}
return 1;
};
$container->items[] = '1';
$a = $func();
and write an appropriate fix for both bugs.
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<string> $items). Update specified-types-closure-use.php expectations accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Here's what I changed:
The pre-existing |
VincentLanglet
left a comment
There was a problem hiding this comment.
In this case we maybe don't need a dedicated shouldNotCarryForwardPropertyFetchInClosure private method
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 <noreply@anthropic.com>
|
Already retrieved that output too — tests completed with only the pre-existing |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add a non regression for
class Foo
{
/** @var list<string> */
private array $items = [];
/** @return list<string> */
public function getItems(): array
{
return $this->items;
}
/** @param list<string> $items */
public function setItems(array $items): void
{
$this->items = $items;
}
}
$container = new Foo();
if ($container->getItems() === []) {
$func = function() use ($container): int {
foreach ($container->getItems() as $item) {}
return 1;
};
$container->setItems(['foo']);
$a = $func();
}
too, and update the fix to supports this.
Don't hesitate if you find other similar non-supported cases.
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 <noreply@anthropic.com>
|
The test suite background task also completed. Already reviewed — 11553 tests passed with only the pre-existing |
VincentLanglet
left a comment
There was a problem hiding this comment.
You're missing more case like ?-> and maybe also static access
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 <noreply@anthropic.com>
|
Already reviewed this output too - all 11553 tests pass with only the pre-existing |
VincentLanglet
left a comment
There was a problem hiding this comment.
This should still report an error for something like
$container = new \stdClass();
$container->items = [];
$result = array_map([1], function() use ($container): int {
foreach ($container->items as $item) {}
return 1;
});
because the function is immediatly called
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 <noreply@anthropic.com>
|
PHPStan static analysis also completed — no errors. Both background tasks confirmed clean. Everything is committed and pushed. |
VincentLanglet
left a comment
There was a problem hiding this comment.
ArrayMapArgVisitor shouldn't be hardcoded in the condition because it's the same for lot of other functions or methods
foo(function () use ($call): void {
assertType('PhpParser\Node\Identifier', $call->name);
}, [1]);
By default PHPStan considers callables passed into function calls to be executed immediately, and callables passed into object method calls to be executed later. These defaults can be overridden with @param-immediately-invoked-callable and @param-later-invoked-callable PHPDoc tags.
I think we should rely on this.
… 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 <noreply@anthropic.com>
|
Already reviewed this output too — 11553 tests passed with only the 2 pre-existing failures (unrelated). Everything is committed and pushed. |
VincentLanglet
left a comment
There was a problem hiding this comment.
You're getting a regression. look at the phpstan build.
------ ----------------------------------
Line src\PhpDoc\TypeNodeResolver.php
------ ----------------------------------
998 Left side of || is always false.
🪪 booleanOr.leftAlwaysFalse
------ ----------------------------------
It seems related to the variable passed by reference
use ($nameScope, &$isVariadic):
…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 <noreply@anthropic.com>
|
Done. Here's what the issue was and how I fixed it: Root cause: The previous commit reused For closures with by-ref variables (like Fix: Introduced a separate attribute |
|
The review from VincentLanglet is an APPROVED review with no additional changes requested. All previous feedback has already been addressed in the existing commits:
The branch is clean and up to date with the remote. No further action is needed — the PR has been approved. |
Summary
When an object with a dynamic property (e.g.
stdClass) was captured byusein an anonymous function, PHPStan incorrectly froze the property's type at closure definition time. This caused false positives like "Empty array passed to foreach" when the property was modified between closure definition and invocation.Changes
shouldNotCarryForwardPropertyFetchInClosure()method insrc/Analyser/MutatingScope.phpto check whether a PropertyFetch expression type should be excluded from forwarding into closure scopestdClass)MethodCall::$name) still carry forward type narrowings frominstanceofcheckstests/PHPStan/Rules/Arrays/data/bug-10345.phpand test method inDeadForeachRuleTest.phpRoot cause
In
MutatingScope::enterAnonymousFunctionWithoutReflection(), expression type holders for non-ref captured variables were carried forward into the closure scope, including PropertyFetch expressions. For objects captured by value, this froze the property type at closure definition time. Since PHP objects are always references (even when captured by value viause), the object's properties can be modified externally before the closure is invoked. Dynamic properties (not declared in the class) are particularly prone to this because their types come entirely from assignment context rather than class declarations.The fix checks whether the property is a native/declared property on the class. For dynamic properties (like on
stdClass), the expression type holder is not carried forward, allowing the property to resolve to its natural type (mixed) inside the closure. For declared properties, type narrowings (e.g., frominstanceofchecks) are still carried forward as before.Test
Added
testBug10345inDeadForeachRuleTestwith a test data file that reproduces the exact scenario from the issue: astdClasswith an empty array property captured by a closure, where the property is later populated before invocation. The test expects no errors.Fixes phpstan/phpstan#10345