Skip to content

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

Open
phpstan-bot wants to merge 7 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 7 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

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.

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

@staabm staabm left a comment

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.

@staabm
staabm force-pushed the create-pull-request/patch-89ch8pu branch from 1a07961 to a33563b Compare July 24, 2026 08:33
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what I found and fixed.

The CI failure staabm reported

The two failing jobs were the Roave/BetterReflection PHAR integration test (and its baseline generation), crashing with a hard fatal:

Class ...InvalidInheritances\ClassExtendingInterface cannot extend interface ...AInterface
in .../test/unit/Fixture/InvalidInheritances.php ... while running parallel worker

Root cause

The crash only happens in the PHAR, never in dev mode — which is why make tests/make phpstan were green but CI was red. It's a php-scoper prefixing mismatch, not a logic bug in the autoloader ordering.

bin/phpstan's old code excluded Composer's own ClassLoader from the bootstrap autoloaders PHPStan invokes for real, via get_class($fn[0]) === \Composer\Autoload\ClassLoader::class. In the PHAR, compiler/build/scoper.inc.php prefixes Composer\Autoload\ClassLoader to _PHPStan_xxx\Composer\Autoload\ClassLoader, but a patcher reverts it to the un-prefixed name for a whitelist of files — and bin/phpstan was on that whitelist.

This PR moved that check into src/autoloadFunctions.php (collectNewAutoloadFunctions), which was not on the whitelist. So in the PHAR the check compared against the prefixed _PHPStan_xxx\Composer\Autoload\ClassLoader, while the analysed project's real Composer loader is un-prefixed Composer\Autoload\ClassLoader. It no longer matched → the project's Composer ClassLoader was no longer excluded → it got collected as a bootstrap autoload function and invoked for real by AutoloadFunctionsSourceLocator, require-ing the invalid fixture file and fatal-crashing — exactly the runtime-vs-analysis divergence class of bug this PR was meant to remove.

The fix

One line: added 'src/autoloadFunctions.php' to the un-prefixing whitelist in compiler/build/scoper.inc.php, mirroring how bin/phpstan and the Testing source-locator factories already handle the same Composer\Autoload\ClassLoader reference.

Verification

  • make phpstan — No errors
  • make tests — 17627 tests, 90022 assertions, OK (97 skipped), exit 0
  • CollectNewAutoloadFunctionsTest — 5 tests pass
  • php -l on the scoper config — clean

Committed as "Un-prefix Composer\Autoload\ClassLoader in scoped src/autoloadFunctions.php" and pushed to create-pull-request/patch-89ch8pu. The actual proof is the PHAR integration CI, which this change targets directly.

One caveat worth flagging: I could not build the PHAR locally to reproduce end-to-end (it needs the box compile toolchain), so this fix is verified by tracing the scoper config rather than by a local PHAR run. The mechanism is confirmed against compiler/build/scoper.inc.php:142-151 and the pre-PR bin/phpstan check — the PHAR integration job on the next CI run is the definitive confirmation.

staabm and others added 6 commits August 6, 2026 12:10
… 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.
…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>
…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>
…ns.php

The Composer ClassLoader identity check used to exclude Composer's own
class loader from the bootstrap autoloaders PHPStan invokes for real was
moved from bin/phpstan into src/autoloadFunctions.php. bin/phpstan is on
the scoper whitelist that reverts Composer\Autoload\ClassLoader back to
its un-prefixed name, but src/autoloadFunctions.php was not, so in the
PHAR the check compared against _PHPStan_xxx\Composer\Autoload\ClassLoader.
The analysed project's real Composer loader is un-prefixed and therefore
no longer matched, so it was collected as a bootstrap autoload function
and invoked for real - require-ing files (e.g. Roave/BetterReflection's
invalid inheritance fixtures) and hard-crashing analysis with a fatal
that cannot happen at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-89ch8pu branch from 635b06f to 3c7833b Compare August 6, 2026 10:11

require_once __DIR__ . '/vendor/autoload.php';

spl_autoload_register(function($class) {

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.

add a comment why/how this test case works and add a note about the autoload function is using prepend=false

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.

Addressed the review comment.

What I changed

e2e/bug-12972b/autoloader.php — added a comment above the spl_autoload_register() call explaining:

  • prepend = false: the third argument defaults to false, so this autoloader lands at the back of the spl_autoload queue, after Composer's class loader. (This is the deliberate counterpart to e2e/bug-12972c, which passes true, true to prepend before Composer and already carried an equivalent note.)
  • Why the case works: \other12972\MyClass is in Composer's class map (composer.json), so at runtime Composer resolves it first and this autoloader is never invoked for it — php real-world.php never throws.
  • What it proves: PHPStan must mirror that order and let the Composer class-map locator resolve the class before consulting this bootstrap autoloader. Before the fix, analysis invoked it first, hit the LogicException, and crashed with an internal error that cannot occur at runtime.

Verification

  • make phpstan — No errors
  • make tests — 21220 tests, 96743 assertions, OK (97 skipped)
  • php -l on the changed file — clean

Committed as "Document how the bug-12972b e2e autoloader reproduces the issue" and pushed to create-pull-request/patch-89ch8pu.

@staabm

staabm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@SanderMuller please review

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

Copy link
Copy Markdown
Contributor

Reviewed. I think this is the right shape and it is complementary to #6185, not overlapping: #6185 stops the redeclare when the probed name is already a defined function (#14988), this one fixes the "autoloader invoked out of its runtime order" family (#14976). Happy to see it land.

What I checked:

The model is correct. Splitting the bootstrap autoloaders by their position relative to Composer's ClassLoader in the spl queue, then consulting the prepended ones before the static locators and the appended ones only as a fallback, is exactly the runtime order. The real-world.php oracles make that concrete: php real-world.php never throws because Composer resolves the class first, and analysis now mirrors that.

Verified fails-before. I reproduced e2e/bug-12972b on current 2.2.x HEAD (8e2efc0ce, the #6185 merge, so it already has the guard) without this PR's reorder. It crashes with Internal error: this should not happen, the throwing appended autoloader invoked before the classmap locator resolves the class. With the reorder your CI has it green. So the fixture genuinely gates the bug, it does not just pass.

Interaction with #6185 is clean. This is rebased on top of it and keeps the function_exists guard. Since that guard sits in locateIdentifier, it now protects both the prepended and the appended instance, and e2e/bug-14988 stays green here. No regression of #14988.

Bonus that I like: collectNewAutoloadFunctions() unifies the two divergent autoloader-collection copies in bin/phpstan and CommandHelper. That closes the gap I flagged as a follow-up on #6185, where CommandHelper had no ClassLoader exclusion. Both paths now go through the same exclusion. And note the change is strictly a reduction in eager execution: before, every bootstrap autoloader ran at the front, now only the prepended ones do.

On the two red checks, neither looks like a correctness problem from this change:

  • Checksum PHAR: the compiled phar differs from base, which is expected since you touch bin/phpstan and scoper.inc.php. There is also an artifact digest-mismatch in that job which reads as infra.
  • Tests with old PHPUnit (8.1): the failure is IntersectionTypeTest::testIsAcceptedBy (a ConstantArrayType describe comparison), code this PR does not touch. It is green on every modern Tests job here and green on other open PRs and on the Do not autoload a class whose name is an already defined function #6185 base commit, so it looks version-specific or flaky rather than caused by the reorder. Worth a re-run to confirm.

Two optional nits, not blockers:

  • When Composer's ClassLoader is not in the queue ($composerIndex === null) everything new is bucketed as appended. That is reasonable and the unit test documents it, a one-line comment in the function saying "no Composer loader, treat all as appended fallback" would make it explicit in the code too.
  • A prepended catch-all autoloader now runs at the front for classes that are also in the analysed paths or the classmap, so PHPStan would reflect the autoloader's version of such a class. That matches runtime (a prepended autoloader wins there too), so I think it is correct, just noting it as the one behavioral edge of putting the prepended bucket first.

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

3 participants