Skip to content

Split bootstrap autoloaders at the project's class loader - #6281

Open
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:autoload-order-composer-missing
Open

Split bootstrap autoloaders at the project's class loader#6281
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:autoload-order-composer-missing

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The ordering half of phpstan/phpstan#15102 - @sbuerk's original report. #6265 fixes @hoetaek's half.

The mechanism is not the one the issue proposes. I built a phar from the first version of this PR (which implemented the suggested $composerIndex === null fallback) and it does not fix the reported case. Measured on the e2e/bug-15102 project added here, each run with its own tmpDir so no result cache is shared:

build result
2.2.8 phar No errors
2.2.9 phar 1 error
phar of this PR's first version ($composerIndex === null fallback) 1 error
this PR No errors

What actually happens

collectNewAutoloadFunctions() looks for the first Composer\Autoload\ClassLoader instance in the queue to decide what came "before Composer". That instance is PHPStan's own - bin/phpstan requires its own autoloader long before any project code runs. Dumping the queue during analysis:

composerIndex=0
  after[0] Composer\Autoload\ClassLoader   <- PHPStan's own loader
  after[1] Hoa\Consistency\Autoloader
  after[2] AliasLoader::loadClass          <- the bootstrap's loader, index > 0
  => APPENDED AliasLoader::loadClass

So every bootstrap-registered autoloader is classified as "after Composer" and consulted only after the static source locators. $composerIndex is never null - which is why the fallback the issue proposes changes nothing on its own.

The fix skips the first ClassLoader in the before-snapshot and splits at the last one, i.e. the analysed project's loader. When that loader is gone after the bootstrap files ran, a bootstrap has replaced Composer (typo3/class-alias-loader unregisters [$composerLoader, 'loadClass'] and registers a wrapper) and there is no boundary left, so the collected loaders are consulted first - the order they had until 2.2.9.

Verification

  • e2e/bug-15102 (new, registered in e2e-tests.yml): a bootstrap replaces the project's loader and resolves Legacy\Validate via class_alias, while an IDE-only stub in the analysed paths declares the same name as a plain class - the TYPO3 shape. Without the fix the stub wins and PHPStan reports Call to an undefined method Legacy\Validate::doFoo(); with it, clean. It reproduces in a source run, so no phar is needed to keep it honest.
  • CollectNewAutoloadFunctionsTest: 6 green, 4 fail without the source change. The cases now model the real snapshot, which always starts with PHPStan's own loader, and testPhpstansOwnLoaderIsNotTheBoundary pins the regression directly.
  • e2e/bug-12972b, e2e/bug-12972c (Consult bootstrap-registered custom autoloaders only after the static source locators #6069's own ordering coverage) and e2e/bug-14988 exit 0.
  • Full suite 21156 green, self-analysis clean, phpcs clean.

Closes the ordering half of phpstan/phpstan#15102

@ondrejmirtes

Copy link
Copy Markdown
Member

phpstan/phpstan, where the compiled PHAR exists, also has e2e/ tests. Would be nice to reproduce it there with actual project, make sure it's red, and then make sure here it's green.

@ondrejmirtes

Copy link
Copy Markdown
Member

If the project is an existing actually-used open-source project and not just a reproducing repository, it could be added to integration-tests.yml (showing errors on the latest 2.2.x) instead of e2e in other-tests.yml.

collectNewAutoloadFunctions() decided whether a bootstrap-registered
autoloader runs before or after the static source locators by looking for
the first Composer ClassLoader instance in the spl_autoload queue. That
instance is PHPStan's own: bin/phpstan requires its own autoloader long
before any project code runs, so every collected autoloader looked like it
came after Composer and was consulted only after the static locators.

The boundary is the analysed project's loader, so skip the first
ClassLoader in the before-snapshot and split at the last one instead. When
that loader is gone from the queue after the bootstrap files ran, a
bootstrap has taken Composer's place - typo3/class-alias-loader
unregisters [$composerLoader, 'loadClass'] and registers a wrapper - and
its loader carries Composer's runtime priority, so the collected
autoloaders are consulted first, as they were until 2.2.9.

e2e/bug-15102 covers the replacement shape: an IDE-only stub in the
analysed paths declares a legacy name that only exists as a class_alias at
runtime. The static locator returns the stub without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SanderMuller
SanderMuller force-pushed the autoload-order-composer-missing branch from 2d20290 to af675e6 Compare August 26, 2026 18:55
@SanderMuller SanderMuller changed the title Consult a Composer replacement before the static source locators Split bootstrap autoloaders at the project's class loader Aug 26, 2026
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Done, and following your ask found that my first version was wrong - so this PR now carries a different fix.

I built a phar from the branch to check it properly. The $composerIndex === null fallback the issue proposes does not fix the reported case: $composerIndex is never null, because the first Composer\Autoload\ClassLoader in the queue is PHPStan's own, registered long before any bootstrap file. Every bootstrap-registered autoloader therefore looks like it came after Composer and gets consulted only after the static locators.

Each run below has its own tmpDir, because PHPStan's result cache key is the same 2.2.x-dev for different source states and I got a false green from it first:

build e2e/bug-15102
2.2.8 phar No errors
2.2.9 phar 1 error
phar of this PR's first version 1 error
this PR No errors

The fix now splits at the project's loader (skip the first ClassLoader in the before-snapshot, take the last), and the replaced-Composer case falls out of it naturally.

On the e2e request: it turns out this half does reproduce in a source run, so the project lives here as e2e/bug-15102 and is wired into e2e-tests.yml - red without the fix, green with it, no phar needed. The stub-vs-alias shape mirrors TYPO3: an IDE-only stub in the analysed paths declares a name that only exists as a class_alias at runtime.

For #6265 I have the same red/green matrix against the release phars (2.2.8 clean, 2.2.9 class.notFound, branch phar clean) - I will follow up there with whether that half can also be covered from source, or whether it needs to go into phpstan/phpstan's e2e as you suggested.

On the real-project alternative: TYPO3 core is the actual consumer here, and typo3/class-alias-loader only misbehaves in combination with a stub file inside the analysed paths, so an integration-tests entry would have to pull TYPO3 v14. Happy to add that instead if you prefer it over the minimal project.

@ondrejmirtes

Copy link
Copy Markdown
Member

Happy to add that instead if you prefer it over the minimal project.

We could have both, it's always nice to add another integration project into the pipeline.

Please rebase, there's now a conflict after merging your other PR.

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.

2 participants