Skip to content

Look through call_user_func() and friends in CallToFunctionStatementWithNoDiscardRule - #6885

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-i486d9p
Closed

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-i486d9p

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan reported nothing when the return value of a #[\NoDiscard] function was thrown away through call_user_func(). That covers both a direct call (call_user_func('test');) and a call through a variable holding the function name ($fn = 'call_user_func'; $fn('test');, from the issue). The rule now looks at the callback passed to call_user_func(), call_user_func_array(), forward_static_call() and forward_static_call_array(), and checks whether that callback is NoDiscard.

PHP itself only warns here when the optimizer turns call_user_func('test') into a direct call; php-src closed php/php-src#25028 as won't fix. Either way, the callback's return value is thrown away, so PHPStan should report it.

Changes

  • src/Rules/Functions/CallToFunctionStatementWithNoDiscardRule.php:
    • When the statement calls one of those four functions by name, replace it with a synthetic call of the callback, taken from the first positional argument or a named callback: argument. This repeats for nested call_user_func('call_user_func', ...) calls.
    • For variable calls, if the callee's type contains constant strings naming one of the four functions, check each constant string separately and combine the results with OR. Strings that aren't callable ('nope') are skipped.
    • (void) casts use the same unwrapping, so (void) call_user_func('canDiscard') now points at the callback that allows discarding, not at call_user_func().
  • Probed analogous cases:
    • Method callables passed to these functions ([$obj, 'm'], ['Foo', 's'], 'Foo::s') and closures / first-class callables already carry mustUseReturnValue through their callable acceptors, so they work through the same code path. Tests cover them.
    • Method and static-method call statements (CallToMethodStatementWithNoDiscardRule, CallToStaticMethodStatementWithNoDiscardRule) have no comparable wrapper, so they need no change.

Root cause

The rule only checked the function being called directly, i.e. call_user_func(), which has no NoDiscard attribute. It never looked at the callback that actually produces the discarded value. In the variable-call path, a union like 'call_user_func'|'nope' isn't definitely callable, so the rule bailed out entirely.

Test

Extended tests/PHPStan/Rules/Functions/data/function-call-statement-result-discarded.php (and the PHP 8.5 variant for (void) casts) with:

  • the issue's reproducer
  • a genuine 'call_user_func'|'nope' union, plus a union where every candidate allows discarding (not reported)
  • direct call_user_func, call_user_func_array, forward_static_call and forward_static_call_array
  • a named callback: argument
  • nested call_user_func('call_user_func', ...)
  • a first-class callable
  • method and static method callables
  • non-NoDiscard callbacks (not reported) and used results (not reported)

Fixes phpstan/phpstan#15604

…ntWithNoDiscardRule`

- Unwrap call_user_func(), call_user_func_array(), forward_static_call() and
  forward_static_call_array() statements into a call of their callback
  (positional or named `callback` argument), so a discarded result of a
  #[\NoDiscard] function/method/closure passed to them is reported.
- Variable calls whose callee is a constant string naming one of these
  functions (e.g. `$fn = 'call_user_func'|'nope'; $fn('test');`) are resolved
  per constant string; strings that are not callable are skipped, and the
  results are combined with OR.
- (void) casts look through the wrapper as well: `(void) call_user_func('canDiscard')`
  reports the callback that allows discarding instead of call_user_func().
- Method callables (`[$obj, 'm']`, `'Foo::s'`, `['Foo', 's']`) passed to these
  functions already carry mustUseReturnValue through their callable acceptors.
@staabm staabm closed this Oct 29, 2026
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.

#[\NoDiscard] Missing error on invoked expression #[\NoDiscard] with PHP function version of call_user_func() does not produce any warning.

2 participants