Skip to content

Fix phpstan/phpstan#6799: wrongly reported empty array#5292

Open
phpstan-bot wants to merge 15 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-39g5cp0
Open

Fix phpstan/phpstan#6799: wrongly reported empty array#5292
phpstan-bot wants to merge 15 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-39g5cp0

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When using call_user_func_array with by-reference arguments (e.g., [&$whereFilter, ...]), PHPStan did not recognize that the referenced variables could be modified by the called function. This caused false positives like Comparison operation ">" between 0 and 0 is always false when checking count($whereFilter) > 0 after the call.

Changes

  • Added by-reference parameter handling for call_user_func_array and call_user_func in src/Analyser/ExprHandler/FuncCallHandler.php
  • After processing the outer call_user_func_array args, the fix resolves the inner callable's parameters via ArgumentsNormalizer::reorderCallUserFuncArrayArguments and assigns the parameter type to any by-reference argument expressions using processVirtualAssign
  • Added regression test tests/PHPStan/Analyser/nsrt/bug-6799.php

Root cause

FuncCallHandler::processExpr called processArgs with call_user_func_array's own parameter signatures. Since call_user_func_array itself doesn't have by-reference parameters, the by-reference handling in processArgs never triggered for the target callable's parameters. The variables passed with & inside the args array were never updated in the scope, so they retained their original types (e.g., array{} for an initially empty array).

Test

The regression test creates a class with a method that modifies an array by reference, then calls that method via call_user_func_array with &$whereFilter. It asserts that $whereFilter has type array<string> after the call, not array{}.

Fixes phpstan/phpstan#6799

- When call_user_func_array (or call_user_func) passes arguments by reference,
  the scope now properly reflects that the referenced variables may be modified
- Added by-reference parameter handling in FuncCallHandler::processExpr that
  resolves the inner callable's parameters and assigns the parameter type to
  by-ref argument expressions via processVirtualAssign
- New regression test in tests/PHPStan/Analyser/nsrt/bug-6799.php
- Root cause: processArgs only handled by-ref for call_user_func_array's own
  parameters, not the target callable's parameters
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a test which calls a method with a param-out phpdoc, the type of the reference should be updated according the param-out one instead of the param one.

When a by-reference parameter has a @param-out PHPDoc, use that type
instead of the @param type when updating scope after call_user_func_array.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Already processed — all tests passed. The fix has been committed and pushed.

Comment on lines +242 to +244
$byRefType = $innerParameter instanceof ExtendedParameterReflection && $innerParameter->getOutType() !== null
? $innerParameter->getOutType()
: $innerParameter->getType();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is

					if ($currentParameter === null) {
						throw new ShouldNotHappenException();
					}

					$argValue = $arg->value;
					if (!$argValue instanceof Variable || $argValue->name !== 'this') {
						$paramOutType = $this->getParameterOutExtensionsType($callLike, $calleeReflection, $currentParameter, $scope);
						if ($paramOutType !== null) {
							$byRefType = $paramOutType;
						} elseif (
							$currentParameter instanceof ExtendedParameterReflection
							&& $currentParameter->getOutType() !== null
						) {
							$byRefType = $currentParameter->getOutType();
						} elseif (
							$calleeReflection instanceof MethodReflection
							&& !$calleeReflection->getDeclaringClass()->isBuiltin()
						) {
							$byRefType = $currentParameter->getType();
						} elseif (
							$calleeReflection instanceof FunctionReflection
							&& !$calleeReflection->isBuiltin()
						) {
							$byRefType = $currentParameter->getType();
						} else {
							$byRefType = new MixedType();
						}

						$scope = $this->processVirtualAssign(
							$scope,
							$storage,
							$stmt,
							$argValue,
							new TypeExpr($byRefType),
							$nodeCallback,
						)->getScope();
						$scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue);
					}
				}

in NodeScopeResolver

There is certainly something to refactor and use here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's a summary of the changes addressing VincentLanglet's review:

  1. Extracted resolveByRefParameterType() on NodeScopeResolver - New public method centralizing the by-reference parameter type resolution logic (extension types via getParameterOutExtensionsType, @param-out via getOutType(), builtin vs user-defined distinction, MixedType fallback). Used in both processArgs and FuncCallHandler.

  2. Added resolveCallUserFuncCalleeReflection() on FuncCallHandler - Resolves the inner callee reflection from the callback expression: handles Name-based function resolution, constant string function names, and array-based method callables via findTypeAndMethodNames().

  3. Added lookForUnsetAllowedUndefinedExpressions() call in FuncCallHandler after the virtual assignment, matching the NodeScopeResolver pattern.

  4. Removed the ExtendedParameterReflection import from FuncCallHandler since that logic now lives in resolveByRefParameterType.

…lver logic

Extract resolveByRefParameterType() public method from NodeScopeResolver to
centralize by-reference parameter type resolution (extension types, @param-out,
builtin vs user-defined distinction). Use it in both processArgs and
FuncCallHandler's call_user_func_array/call_user_func handling.

Also resolves the inner callee reflection (function or method) from the callback
type, and calls lookForUnsetAllowedUndefinedExpressions after virtual assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

$callbackType = $scope->getType($innerFuncCall->name);

foreach ($callbackType->getConstantStrings() as $constantString) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those two foreach loops are not correct since they return the first result meaning that a call to 'foo'|'bar' will be considered as a call to 'foo'.

Let's just simplify and support only when there is 1 value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The change simplifies resolveCallUserFuncCalleeReflection to only resolve the callee when there's exactly one constant string or one constant array callback value, instead of iterating with foreach loops that incorrectly returned the first match from union types like 'foo'|'bar'.

All tests pass (11656 tests, 78894 assertions) and make phpstan reports no errors.

…alue callbacks

Instead of iterating and returning the first match (which incorrectly
handles union types like 'foo'|'bar' by only considering 'foo'),
only resolve the callee when there is exactly one constant string or
one constant array callback value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a test for an union of string 'method1'|'method2'
do the same for an union of constantArray.

Also add a test for 'method1'|constantArray

…dling

Tests cover:
- Union of string method names ('method1'|'method2')
- Union of constant array callbacks
- Mixed union of string and array callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Already handled — all 11656 tests passed. The commit has been pushed.

phpstan-bot and others added 3 commits March 25, 2026 06:34
- Add native return type to resolveCallUserFuncCalleeReflection
- Move $this check to be first inside the loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…s check

- Add native return type to resolveCallUserFuncCalleeReflection
- Add early return null after constant strings block since a type
  cannot be both constant string and constant array
- Move $this check to be first inside the loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
phpstan-bot and others added 2 commits March 25, 2026 06:49
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…type

- Lazy-initialize innerCalleeReflection with ??= so reflection work
  only runs after the passedByReference()->createsNewVariable() check
- Use native union parameter type on resolveByRefParameterType

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…parison

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet
Copy link
Copy Markdown
Contributor

VincentLanglet commented Mar 25, 2026

@staabm

$innerCalleeReflection ??= $this->resolveCallUserFuncCalleeReflection($innerFuncCall, $scope);

does not seems to be a good optimisation since this metod might return null.

So in this case you will call it in every step of the loop.

Is it ok to put it back before the loop ?

Best would be to simplify resolveCallUserFuncCalleeReflection I dunno how to avoid using it.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Mar 25, 2026

does not seems to be a good optimisation since this metod might return null.

So in this case you will call it in every step of the loop.

Is it ok to put it back before the loop ?

I think its pretty unlikely that we use call_user_func with a function expecting a reference at all. so I think the "optimization" saves more on the general case, because we don't need to resolve reflection for most uses of call_user_func (which don't use references at all).

it will only be invoked multiple times if you have a callable which expects more than 1 by-ref param, which is even more unlikely.

but sure, there is room for improvement

Best would be to simplify resolveCallUserFuncCalleeReflection I dunno how to avoid using it.

agree but I don't know yet. will have another look.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolveCallUserFuncCalleeReflection method seems complicated and it would be better to avoid it because we have a nicer abstraction for that - CallableParametersAcceptor.

It would be better to add a method to the CallableParametersAcceptor (if needed) and use it here.

…ceptor::getCalleeReflection()

Add getCalleeReflection() to CallableParametersAcceptor interface, returning
the underlying FunctionReflection or MethodReflection when available. This
replaces the manual callable resolution logic in FuncCallHandler with the
existing callable type abstraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

That was the earlier make tests run — all 11657 tests passed (78895 assertions). Everything is green and the commit has already been pushed.

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.

3 participants