Skip to content

Consult bootstrap-registered custom autoloaders only after the static source locators#6069

Open
phpstan-bot wants to merge 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-89ch8pu
Open

Consult bootstrap-registered custom autoloaders only after the static source locators#6069
phpstan-bot wants to merge 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-89ch8pu

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When a project registers a custom autoloader from a bootstrap file (e.g. via spl_autoload_register in a bootstrapFiles script), 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 the AutoloadFunctionsSourceLocator from the very front of the locator chain to just before the runtime AutoloadSourceLocator, 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 (and CommandHelper) collect the autoload functions registered by bootstrap files into $GLOBALS['__phpstanAutoloadFunctions'], but deliberately exclude Composer's own ClassLoader (Composer-mapped classes are resolved statically by the Composer source locators instead). AutoloadFunctionsSourceLocator then 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 the FileReadTrapStreamWrapper) was checked and does not have this bug: it runs the live spl_autoload_functions() (Composer included) in order and stops at the first autoloader that reads a file, so it already reproduces runtime ordering faithfully. TestCaseSourceLocatorFactory does not use AutoloadFunctionsSourceLocator at all. Functions and constants are unaffected because PHP only autoloads classes and the locator early-returns for non-class identifiers.

Test

  • e2e/bug-12972b reproduces the reported case: a Composer class-map project whose bootstrap autoloader throws for \other12972\MyClass. Before the fix bin/phpstan analyze crashes with Internal error: this should not happen; after the fix it reports no errors, matching php real-world.php at runtime.
  • Verified manually that the analogous PSR-4 variant is also fixed, and that the "class only obtainable by running a custom autoloader" feature (e.g. an 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

staabm and others added 2 commits July 20, 2026 14:42
… 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.
Comment on lines +3 to +9
require_once __DIR__ . '/vendor/autoload.php';

spl_autoload_register(function($class) {
if ($class === \other12972\MyClass::class) {
throw new LogicException('this should not happen');
}
});

@staabm staabm Jul 20, 2026

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.

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?

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.

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 — new collectNewAutoloadFunctions() helper that finds Composer's ClassLoader in the queue and classifies each newly-registered autoloader as prepended (before Composer) or appended (after Composer), while still excluding Composer itself, the phar loader, and pre-existing autoloaders. Also a new autoloadFunctionsPrependedToComposer() accessor. The logic is robust to PHPStan's own dev ClassLoader also 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 $prependedToComposer flag 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 into e2e-tests.yml.

Verification

  • make phpstan — No errors
  • Full make tests (paratest) — 17607 tests, exit 0
  • phpcs on all changed .php files — clean
  • Existing e2e bug-12972b (appended throwing autoloader) — still no crash

Both commits are pushed to create-pull-request/patch-89ch8pu.

phpstan-bot and others added 2 commits July 20, 2026 15:33
…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,

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.

don't use a default. callers should explcitly pass which bool value they expect.

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.

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 errors
  • make 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.

@staabm

staabm commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

…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>
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.

PHPStan autoloader mechanics invoked for case which does not use autoloading at runtime

2 participants