From a5f55ef0ab00a0c5872a5e93ac4506bd11cb550f Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:06:34 +0000 Subject: [PATCH 1/6] Enter right-side-assign context for `??=` so closures capturing the assigned 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. --- src/Analyser/ExprHandler/AssignOpHandler.php | 8 ++++++++ tests/PHPStan/Analyser/nsrt/bug-13810.php | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13810.php diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 31af0b9c77f..6208008ee9c 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -29,6 +29,7 @@ use PHPStan\Type\Type; use function array_merge; use function get_class; +use function is_string; use function sprintf; /** @@ -68,6 +69,13 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $scope = $scope->filterByFalseyValue( new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), ); + + if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $context = $context->enterRightSideAssign( + $expr->var->name, + $expr->expr, + ); + } } $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13810.php b/tests/PHPStan/Analyser/nsrt/bug-13810.php new file mode 100644 index 00000000000..c5e801c4797 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13810.php @@ -0,0 +1,17 @@ + Date: Sat, 20 Jun 2026 09:00:58 +0200 Subject: [PATCH 2/6] more tests --- tests/PHPStan/Analyser/nsrt/bug-13810.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13810.php b/tests/PHPStan/Analyser/nsrt/bug-13810.php index c5e801c4797..5fb5ea1b1a4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13810.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13810.php @@ -4,9 +4,22 @@ use function PHPStan\Testing\assertType; -function test(): void +function doFoo(): void { static $isSupported; + assertType('mixed', $isSupported); + $isSupported ??= function (mixed $arg) use (&$isSupported): bool { + assertType('Closure(mixed): bool', $isSupported); + return $isSupported($arg); + }; + + assertType('mixed~null', $isSupported); + $isSupported('foo'); +} + +function doBar($isSupported): void +{ + assertType('mixed', $isSupported); $isSupported ??= function (mixed $arg) use (&$isSupported): bool { assertType('Closure(mixed): bool', $isSupported); return $isSupported($arg); From dcd56f0807895f7e96dd2654cade8e2657d244e9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 20 Jun 2026 09:06:01 +0200 Subject: [PATCH 3/6] more tests --- tests/PHPStan/Analyser/nsrt/bug-13810.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13810.php b/tests/PHPStan/Analyser/nsrt/bug-13810.php index 5fb5ea1b1a4..838e4571593 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13810.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13810.php @@ -28,3 +28,16 @@ function doBar($isSupported): void assertType('mixed~null', $isSupported); $isSupported('foo'); } + +function doFooBar(): void +{ + $isSupported = null; + assertType('null', $isSupported); + $isSupported ??= function (mixed $arg) use (&$isSupported): bool { + assertType('Closure(mixed): bool', $isSupported); + return $isSupported($arg); + }; + + assertType('Closure(mixed): bool', $isSupported); + $isSupported('foo'); +} From e78535a66c762a54962bfe50d74a89904d2a2839 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 20 Jun 2026 09:11:26 +0200 Subject: [PATCH 4/6] add rule-test --- tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index ed1047d0207..aec252f89b4 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -429,4 +429,9 @@ public function testMaybeNotCallable(): void $this->analyse([__DIR__ . '/data/maybe-not-callable.php'], $errors); } + public function testBug13810(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13810.php'], []); + } + } From 694a1f9e3fe2e014d7fff53ea4e950309a5e3136 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 20 Jun 2026 09:15:05 +0200 Subject: [PATCH 5/6] Update bug-13810.php --- tests/PHPStan/Analyser/nsrt/bug-13810.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13810.php b/tests/PHPStan/Analyser/nsrt/bug-13810.php index 838e4571593..c17f1a1fedf 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13810.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13810.php @@ -1,4 +1,6 @@ -= 8.0 + +declare(strict_types = 1); namespace Bug13810; From 06561b1a218d2264e95a807e956a0fcc1f1c6b41 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 20 Jun 2026 09:20:34 +0200 Subject: [PATCH 6/6] another test --- tests/PHPStan/Analyser/nsrt/bug-13810.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13810.php b/tests/PHPStan/Analyser/nsrt/bug-13810.php index c17f1a1fedf..c1377cba0e4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13810.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13810.php @@ -43,3 +43,26 @@ function doFooBar(): void assertType('Closure(mixed): bool', $isSupported); $isSupported('foo'); } + +class HelloWorld +{ + public function setValue(mixed $value): void + { + /** @var ?callable $isSupported */ + static $isSupported = null; + $isSupported ??= function(mixed $arg) use (&$isSupported): bool { + if (is_array($arg)) { + foreach($arg as $value) { + if (!$isSupported($value)) { + return false; + } + } + return true; + } + return is_string($arg); + }; + if (!$isSupported($value)) { + throw new InvalidArgumentException('only strings/string arrays are supported'); + } + } +}