Skip to content

Ask isCallable() of callable-like arrays and strings only where the answer is consumed - #6559

Merged
ondrejmirtes merged 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-zu1gstu
Sep 23, 2026
Merged

ondrejmirtes merged 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-zu1gstu

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A two-item array such as ['BigContainer', 'arbitrary_ident'] looks like a callable. Asking its type isCallable() makes PHPStan locate and reflect BigContainer to see whether it has an arbitrary_ident method. PHPStan asked this in several places even when nothing used the array as a callable, for example a private class constant used only in in_array(). With a large generated class (a compiled DI container), this made single-file analysis use hundreds of MB.

This change asks isCallable() only where the answer is used. On the issue's reproducer (a class with 25,000 methods), analysing the file drops from 288 MB to 34.5 MB (30.5 MB with the turbo extension). The file with the reversed item order, which never matched a class, uses 52.5 MB.

Changes

  • src/Analyser/ExprHandler/ArrayHandler.php: the array literal's type callback asked isCallable() of every two-item array and only then checked whether a narrowed is_callable($array) expression was in scope. The cheap scope lookup now runs first, and isCallable() is asked only when that narrowing exists.
  • src/Analyser/ArgumentsHandler.php: isCallable() was asked of every argument, including arrays and strings passed to plain array/string parameters like in_array()'s haystack. It is now asked only when the result is used:
    • the callback is called immediately (callCallbackImmediately()), or
    • callback-expression invalidation applies and the argument can be an object. Only closures carry invalidate expressions or used variables; the acceptors of callable arrays and strings return [] for both.
  • src/Dependency/DependencyResolver.php: the Array_ branch (dependencies on the return types of callable arrays) is skipped for class-level initializers: class constants, property defaults and enum case values. Nothing calls these where they are declared.
  • src/Reflection/GenericParametersAcceptorResolver.php (analogous case): inferPredicateTemplateTypes() asked every argument of every generic function isCallable() before checking whether the parameter is a callable with asserts. The order is now reversed. This also covered 'Class::method' string arguments.
  • turbo-ext/src/ArrayHandler.cpp, turbo-ext/src/ArgumentsHandler.cpp: the same changes ported to the native mirrors. A follow-up make bump-turbo commit is needed once this commit has its final SHA.
  • Probed and left unchanged, because they ask isCallable() only of values that are actually used as callables: UnusedPrivateMethodRule, SimpleImpurePoint, FuncCallHandler, ArgumentsNormalizer, TypeSpecifier, CallCallablesRule, FunctionCallableRule.
  • Still reflected on purpose: a callable-like array assigned to a variable inside a function body. The dependency resolver keeps testing it because the variable may be called later.

Root cause

ConstantArrayType::isCallable() and ConstantStringType::isCallable() have to reflect the named class to answer. Several analyser paths asked this unconditionally, "just in case", before checking whether the answer would matter. The pattern: an expensive type query ordered before a cheap check that usually makes it unnecessary. Affected locations:

  • ArrayHandler's type callback
  • ArgumentsHandler::processArgs()
  • DependencyResolver::collectNodeDependencies()
  • GenericParametersAcceptorResolver::inferPredicateTemplateTypes()

Test

tests/PHPStan/Analyser/Bug15292Test.php registers a methods class reflection extension that records every hasMethod() question. It asserts:

  • testCallableLikeValuesAreNotReflected (data/bug-15292.php): the class named in a callable-like class constant, a property default, an enum constant, and a 'Class::method' string argument to in_array() is never asked about. Without the fix, four method lookups are recorded. Each of the three changed analyser/dependency files is needed on its own; reverting any one brings back some of them.
  • testCallableLikeGenericArgumentsAreNotReflected (data/bug-15292-generic.php): the same for arguments to a generic function. It fails without the GenericParametersAcceptorResolver change.
  • testCallableIsReflected (data/bug-15292-callable.php): a positive control. An array passed to usort() is still reflected as a callable.

Also verified:

  • make tests passes with and without the turbo extension loaded.
  • turbo-ext/tests/walk-trace.php produces identical traces.
  • smoke.php, side-by-side.php and signature-parity.php pass.
  • make phpstan passes.
  • make name-collision fails, but on an existing file it cannot parse (tests/PHPStan/Build/data/final-class-rule-pipe.php); it fails the same way on the base branch.

Fixes phpstan/phpstan#15292

🤖 Generated with Claude Code

phpstan-bot and others added 2 commits September 23, 2026 12:27
… answer is consumed

- ArrayHandler: check for a narrowed `is_callable()` expression in scope before asking the
  array literal type `isCallable()`, instead of asking it for every two-item array literal
- ArgumentsHandler: ask the argument type `isCallable()` only when the result is used - when the
  callback is called immediately, or when invalidation applies and the argument can be an
  object (only closures carry expressions to invalidate)
- DependencyResolver: skip the callable-array dependency for class-level initializers (class
  constants, property defaults, enum case values), which are never called where declared
- GenericParametersAcceptorResolver: ask the argument type `isCallable()` only once a callable
  parameter with asserts is found (same eager reflection for generic function arguments)
- Mirror the ArrayHandler and ArgumentsHandler changes in turbo-ext
- Probed and left as is: UnusedPrivateMethodRule, SimpleImpurePoint, FuncCallHandler and other
  callers ask `isCallable()` only of values that are actually used as callables

Answering `isCallable()` of `['SomeClass', 'someString']` or `'SomeClass::someString'` locates
and reflects `SomeClass`, which for a huge generated class costs hundreds of MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-zu1gstu branch from a32405e to 1f8dd0a Compare September 23, 2026 10:28
@ondrejmirtes
ondrejmirtes merged commit 1f8dd0a into phpstan:2.3.x Sep 23, 2026
240 of 244 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-zu1gstu branch September 23, 2026 10:28
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.

Memory spike from eager callable class reflection on two-element array constants

2 participants