Consult bootstrap-registered custom autoloaders only after the static source locators#6069
Consult bootstrap-registered custom autoloaders only after the static source locators#6069phpstan-bot wants to merge 5 commits into
Conversation
… source locators - Move `AutoloadFunctionsSourceLocator` in `BetterReflectionSourceLocatorFactory` from the front of the locator chain to just before the runtime `AutoloadSourceLocator`, so it runs only as a fallback after the analysed-file, Composer class-map/PSR-4 and PHP-internal locators. - Previously the custom autoloaders registered by bootstrap files (stored in `__phpstanAutoloadFunctions`, from which Composer's own ClassLoader is deliberately excluded) were invoked eagerly for every class name, even for classes that Composer resolves statically at runtime. Because Composer's loader is registered first at runtime, those custom autoloaders are never reached for such classes - PHPStan invoking them anyway ran code paths that cannot happen at runtime (e.g. an autoloader that throws for a class Composer already provides). - Classes that only a custom autoloader can produce (e.g. `eval`'d classes) are still handled, since the locator remains in the chain as a last resort. - Adds e2e test `e2e/bug-12972b` reproducing the reported crash and wires it into the e2e workflow.
| require_once __DIR__ . '/vendor/autoload.php'; | ||
|
|
||
| spl_autoload_register(function($class) { | ||
| if ($class === \other12972\MyClass::class) { | ||
| throw new LogicException('this should not happen'); | ||
| } | ||
| }); |
There was a problem hiding this comment.
the current fix depends on the order in which autoloaders are registered within bootstrap scripts (and whether they are prepend'ed or not).
in case we change the autoloader.php to
<?php
require_once __DIR__ . '/vendor/autoload.php';
spl_autoload_register(function($class) {
if ($class === \other12972\MyClass::class) {
throw new LogicException('this should not happen');
}
}, true, true);
we would get back a similar runtime vs. static analysis inconsistency.
can we detect in which order autoloaders are registered in the bootstrap scripts?
There was a problem hiding this comment.
All verification passes. Here's a summary of what I did to address staabm's review.
The review concern
staabm pointed out that the PR's fix (moving all bootstrap autoloaders to the back of the source-locator chain) depends on registration order: an autoloader registered before Composer's class loader — e.g. spl_autoload_register($fn, true, true) — runs before Composer at runtime, so consulting it after the static Composer locators reintroduces the same runtime-vs-analysis inconsistency in the opposite direction. They asked: can we detect in which order autoloaders are registered?
What I implemented
Yes — I now detect each bootstrap autoloader's position relative to Composer's ClassLoader in the spl_autoload queue and split them into two groups:
src/autoloadFunctions.php— newcollectNewAutoloadFunctions()helper that finds Composer'sClassLoaderin the queue and classifies each newly-registered autoloader asprepended(before Composer) orappended(after Composer), while still excluding Composer itself, the phar loader, and pre-existing autoloaders. Also a newautoloadFunctionsPrependedToComposer()accessor. The logic is robust to PHPStan's own devClassLoaderalso being in the queue (prepends always land at index 0; appends always at the end).bin/phpstan+src/Command/CommandHelper.php— both collection sites now use the shared helper and populate two globals instead of one. This also de-duplicates the two near-identical loops into one place.AutoloadFunctionsSourceLocator— gained a$prependedToComposerflag selecting which group to run.BetterReflectionSourceLocatorFactory— the prepended group is consulted at the front of the chain (before analysed files / Composer class-map / PSR-4), the appended group only as a fallback at the back — mirroring runtime order in both directions.
Tests
tests/PHPStan/CollectNewAutoloadFunctionsTest.php— unit tests for the ordering logic (before/after Composer, exclusions, pre-existing filtering).e2e/bug-12972c— a discriminating e2e: a bootstrap autoloader prepended before Composer resolves a class Composer also maps but with a different API. At runtime the prepended version wins; I verified PHPStan now reflects that same version (calling the Composer-only method correctly errors, proving the prepend order is honored). Wired intoe2e-tests.yml.
Verification
make phpstan— No errors- Full
make tests(paratest) — 17607 tests, exit 0 - phpcs on all changed
.phpfiles — clean - Existing e2e
bug-12972b(appended throwing autoloader) — still no crash
Both commits are pushed to create-pull-request/patch-89ch8pu.
…tion order Bootstrap-registered custom autoloaders were all consulted after the static source locators. That mirrors runtime only for autoloaders registered after Composer's class loader; one registered before it (e.g. spl_autoload_register($fn, true, true)) runs before Composer at runtime and must be consulted before the static Composer locators too. Detect each autoloader's position relative to Composer's ClassLoader in the spl_autoload queue and split them into a prepended and an appended group. The prepended group is consulted before the static locators (front of the chain), the appended group only as a fallback after them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bug-12972c registers a bootstrap autoloader before Composer's class loader that resolves a class Composer also maps, but with a different API. At runtime the prepended autoloader wins; PHPStan now reflects the same version instead of Composer's, so analysis matches runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| public function __construct( | ||
| private AutoloadSourceLocator $autoloadSourceLocator, | ||
| private ReflectionClassSourceLocator $reflectionClassSourceLocator, | ||
| private bool $prependedToComposer = false, |
There was a problem hiding this comment.
don't use a default. callers should explcitly pass which bool value they expect.
There was a problem hiding this comment.
I've addressed staabm's review comment.
What I changed
AutoloadFunctionsSourceLocator — removed the = false default on the $prependedToComposer constructor parameter, so callers must now pass the boolean explicitly.
BetterReflectionSourceLocatorFactory — the appended (fallback) locator construction site, which previously relied on the default, now passes false explicitly. The prepended site already passed true.
Verification
make phpstan— No errorsmake tests— 17607 tests, OK (97 skipped, exit 0)CollectNewAutoloadFunctionsTest— 5 tests pass
Committed as "Require explicit $prependedToComposer argument on AutoloadFunctionsSourceLocator" and pushed to create-pull-request/patch-89ch8pu.
…urceLocator Callers now pass the boolean explicitly instead of relying on a default, making the prepended/appended intent visible at each construction site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
When a project registers a custom autoloader from a bootstrap file (e.g. via
spl_autoload_registerin abootstrapFilesscript), PHPStan could invoke that custom autoloader for a class that at runtime is resolved by Composer's class loader and therefore never reaches the custom autoloader at all. Autoloaders that legitimately assume "I am never called for this class because Composer handles it first" — for example one that throws for such a class — caused PHPStan to crash with an internal error that cannot happen at runtime.This PR makes PHPStan consult those bootstrap-registered custom autoloaders only as a fallback, after the static source locators (analysed files, Composer class map/PSR-4, PHP internals) — mirroring the runtime order where Composer's loader resolves the class first.
Changes
src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php: moved theAutoloadFunctionsSourceLocatorfrom the very front of the locator chain to just before the runtimeAutoloadSourceLocator, so it only runs after the analysed-file locators, the Composer class-map/PSR-4 locators and the PHP-internal locator.e2e/bug-12972b/*: new e2e project reproducing the reported crash (Composer class-map autoload plus a bootstrap autoloader that throws for a class Composer already provides)..github/workflows/e2e-tests.yml: run the new e2e project.Root cause
bin/phpstan(andCommandHelper) collect the autoload functions registered by bootstrap files into$GLOBALS['__phpstanAutoloadFunctions'], but deliberately exclude Composer's ownClassLoader(Composer-mapped classes are resolved statically by the Composer source locators instead).AutoloadFunctionsSourceLocatorthen executes those remaining functions for real.The locator was placed at the front of the chain, so for any class name it invoked the custom autoloaders before the Composer / analysed-file locators had a chance to resolve the class statically. Because Composer's loader had been stripped from that list, the ordering guarantee that exists at runtime ("Composer loads the class first, the custom autoloader is never reached") was lost, and the custom autoloader was run for a class it would never see at runtime.
Reordering the chain restores that guarantee: statically resolvable classes are located by the earlier locators and the custom autoloaders are never invoked for them. The pattern — "runtime autoloaders invoked out of their runtime order" — had a single systemic cause (the locator's position), so a single reordering fixes the whole family rather than one class shape.
The sibling runtime locator
AutoloadSourceLocator(with theFileReadTrapStreamWrapper) was checked and does not have this bug: it runs the livespl_autoload_functions()(Composer included) in order and stops at the first autoloader that reads a file, so it already reproduces runtime ordering faithfully.TestCaseSourceLocatorFactorydoes not useAutoloadFunctionsSourceLocatorat all. Functions and constants are unaffected because PHP only autoloads classes and the locator early-returns for non-class identifiers.Test
e2e/bug-12972breproduces the reported case: a Composer class-map project whose bootstrap autoloader throws for\other12972\MyClass. Before the fixbin/phpstan analyzecrashes withInternal error: this should not happen; after the fix it reports no errors, matchingphp real-world.phpat runtime.eval-based autoloader) still works — the generated class is fully reflected as a fallback (undefined-method calls on it are still reported).Fixes phpstan/phpstan#14976