Skip to content

Decline a bootstrap autoloader only when it would re-include a loaded file - #6265

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
SanderMuller:autoloader-function-name-collision
Aug 26, 2026
Merged

Decline a bootstrap autoloader only when it would re-include a loaded file#6265
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
SanderMuller:autoloader-function-name-collision

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes the second regression reported in phpstan/phpstan#15102 - the one @hoetaek reduced to a portable repro. This is not the ordering issue that opened that ticket; that one is #6069's heuristic and needs its own change.

What was wrong

#6185 added this to AutoloadFunctionsSourceLocator:

if (function_exists($className)) {
    return null;
}

Classes and functions occupy separate symbol spaces, so a class name coinciding with a function name is legal and common. Laravel is the worst case: the facade aliases Cache, File, Str, Hash coincide with the global helpers cache(), str() and with PHP's own file(), hash(). Illuminate\Foundation\AliasLoader creates those aliases lazily from a prepended autoloader, so with the guard in place use Cache; reports class.notFound for every larastan user. Verified against the release phars on @hoetaek's repro:

File (collides with file()) Alias (no such function)
2.2.8 resolved resolved
2.2.9 class.notFound resolved

What the hazard actually was

phpstan/phpstan#14988 was not about the name. A catch-all autoloader - PHP_CodeSniffer's, falling back to Composer's findFile() - resolved a class name to a function's file and plain-included it a second time, fatally redeclaring the function.

So the check now asks that question instead of guessing from the name: probe the autoloaders under FileReadTrapStreamWrapper, which reports which file they would read without executing it, and decline only when that file is already in get_included_files(). An autoloader that defines the class without reading a file - class_alias(), eval() - runs exactly as it did in 2.2.8. The probe only runs for names that actually collide with a function, and not at all when the bucket holds no autoloaders - so a project without bootstrap autoloaders never reaches it: analysing src/Rules triggers 0 probes. Where it does run, get_included_files() held ~35 entries, so the scan is nothing.

One subtlety the trap forces: it intercepts file reads, not execution, so the probe really does run the autoloaders. An autoloader that defines the class without reading a file has therefore already done its work by the time the probe returns, and calling it again would redeclare what it defined - class_alias() warns that the name is already in use. So when the class exists after the probe, the locator reflects it directly instead of looping over the autoloaders again; the test asserts the autoloader runs exactly once. For the same reason the probe stops at the first autoloader that defines the name, the way spl_autoload_call() does - otherwise a later catch-all autoloader in the same bucket could still resolve that name to a loaded file and veto a class the first one had already defined.

Verification

  • New AutoloadFunctionsSourceLocatorTest pins two cases, both verified failing without the change: the alias case (on 2.2.x the locator declines and returns null), and a defining autoloader followed by a catch-all one in the same bucket. It also asserts the autoloader is invoked exactly once - the outcome assertion alone passes either way, only the count (2 vs 1) separates them.
  • e2e/bug-14988 still exits 0 - the redeclare fatal does not come back, it is prevented by the trap rather than by the name.
  • e2e/bug-12972b, e2e/bug-12972c (Consult bootstrap-registered custom autoloaders only after the static source locators #6069) and CollectNewAutoloadFunctionsTest unchanged and green.
  • Full suite 21157 green, self-analysis clean, phpcs clean, rebased on current 2.2.x.
  • Any red checks are base breakage rather than this branch - at the time of writing that was Result cache E2E bug-11826, the phpstan-doctrine lane (Ask the node-callback scope directly instead of toMutatingScope() phpstan-doctrine#789 fixes it once 2.2.10 is out) and the integration tests, all red on every PR.

Coverage: e2e/bug-15102b covers it - red without the fix (2x class.notFound), green with it. An earlier version of this description claimed only a phar exhibits this; that was wrong, and came from a result cache shared between source states (same 2.2.x-dev cache key). Each measurement now uses a fresh tmpDir.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

@ondrejmirtes rebased onto e6357e52c and ready for review - this is the 2.2.9 autoloader regression (phpstan/phpstan#15102), where my function_exists() guard from #6185 declined class names that merely coincide with a function name, which is every larastan facade alias (Cache, File, Str, Hash).

Instead of guessing from the name it now probes the autoloaders under the file-read trap and declines only when they would re-include an already-loaded file, which is what #14988 was actually about. Diff is +218/-17 across the locator and its new test.

Verified locally on the rebased head:

  • AutoloadFunctionsSourceLocatorTest - two cases, both confirmed failing without the change: the alias case, and a defining autoloader followed by a catch-all one in the same bucket. It also pins that the autoloader runs exactly once; the outcome assertion alone passes either way.
  • e2e/bug-14988 exits 0 - the redeclare fatal stays fixed, now prevented by the trap rather than by the name.
  • e2e/bug-12972b, e2e/bug-12972c and CollectNewAutoloadFunctionsTest unchanged and green.
  • Full suite 21157 green, self-analysis [OK] No errors, phpcs clean.

GitHub Actions is in a major outage right now (no runs repo-wide since 15:32Z), so the checks here will only populate once it recovers.

One thing worth knowing for coverage: an e2e/* project cannot pin this, because those run bin/phpstan from source and the class is then rescued further down the locator chain - only the phar exhibits it end to end. Hence the unit-level regression test.

… file

The guard added for phpstan/phpstan#14988 declined this locator whenever a
function of the class's name existed. Classes and functions live in separate
symbol spaces, so that also blocked class names which merely coincide with a
function - Laravel's facade aliases are exactly that shape, since Cache, File,
Str and friends coincide with the global helpers cache(), file() and str(), and
`use Cache;` started reporting class.notFound.

The hazard was never the name: it was a catch-all autoloader resolving a class
name to the function's own file and including it a second time. Probing the
autoloaders under the file-read trap says which file they would read without
executing it, so only that case declines. A loader that defines the class
without reading a file - class_alias(), eval() - now runs as it did before.

Closes phpstan/phpstan#15102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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.

An autoloader that resolves a short alias whose name is also a global
function - Laravel's Cache, File, Str and Hash all are - is declined by
the locator without the fix, so `use File;` reports class.notFound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Added as e2e/bug-15102b (wired into e2e-tests.yml) - and a correction to what I wrote earlier: this half does reproduce in a source run, so it does not need the phar-based e2e in phpstan/phpstan after all. My earlier claim that only the phar exhibits it was wrong; I had been fooled by PHPStan's result cache, whose key is the same 2.2.x-dev for different source states, so a run against unfixed code replayed a green result from a fixed run. With a fresh tmpDir per run:

build e2e/bug-15102b
2.2.x without this fix (source run) 2 errors: class.notFound
this branch (source run) No errors

I also checked it against the release phars on a copy of the same project, which shows the regression window:

phar result
2.2.8 No errors
2.2.9 Parameter $file of anonymous function has invalid type File. + Call to method doFoo() on an unknown class File.
phar built from this branch No errors

The project is the reduced facade-alias shape: a prepended autoloader that resolves File through class_alias() and reads no file, with File colliding with the global file() function - the same mechanism as Laravel's AliasLoader for Cache, File, Str and Hash.

Gates on the pushed head: full suite 21157, self-analysis clean, e2e/bug-14988, bug-12972b, bug-12972c all exit 0.

On the real-project route you suggested: larastan is already in integration-tests.yml, but its suite does not register AliasLoader from a bootstrap file, so it stays green either way - a Laravel app skeleton would be needed to show it. Happy to add that instead if you would rather have a real project than the reduced one.

@ondrejmirtes
ondrejmirtes merged commit fd58278 into phpstan:2.2.x Aug 26, 2026
750 of 765 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you!

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