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!

@hoetaek

hoetaek commented Aug 27, 2026

Copy link
Copy Markdown

Heads-up: the Laravel half of phpstan/phpstan#15102 is still red on the merged 2.2.x tip. The new guard declines exactly the case it was written to keep working, and I think the condition can be narrowed to fix it without giving up the #14988 protection.

Your cache warning was the reason I caught my own bad measurement first: every run below has its own empty tmpDir, because my initial reading was a false red replayed from a cache written by an earlier 2.2.9 run (Result cache restored. 0 files will be reanalysed). Thank you for writing that down.

build reproduction below
2.2.8 release phar [OK] No errors
2.2.9 release phar class.notFound
2.2.x-dev@9680cc4 (contains fd58278 #6265, c10897b #6281, and the early-exit commit) class.notFound

Minimal reproduction (portable — no Laravel, no larastan)

// composer.json
{
    "require-dev": { "phpstan/phpstan": "2.2.x-dev" },
    "autoload": { "psr-4": { "Repro\\": "src/" } }
}
# phpstan.neon
parameters:
    level: 0
    paths: [src]
    bootstrapFiles: [bootstrap.php]
<?php // bootstrap.php - mirrors Illuminate\Foundation\AliasLoader::prependToLoaderStack()
spl_autoload_register(static function (string $class): void {
    if ($class === 'File') {
        class_alias(\Repro\Target::class, 'File');
    }
}, true, true);
<?php // src/Consumer.php   (src/Target.php is any plain class)
namespace Repro;

use File;

final class Consumer
{
    public function run(): File
    {
        return new File();
    }
}

Where it stops

The phar extracts and phar-extract/bin/phpstan runs directly and reproduces, so I could instrument the real code in place:

[SPLIT] projectLoaderId=3 | composerIndex=2 | prepended=1, appended=0
[LOC]   bucket=prepended | count=1 | function_exists=Y
[GUARD] locatedFiles=[".../src/Target.php"]
[GUARD] collision: .../src/Target.php is already included -> declined
[LOC]   -> declined: wouldReIncludeALoadedFile=true

Your #6281 split is doing the right thing here — the bootstrap closure lands in prepended, exactly as intended. What stops it is wouldReIncludeALoadedFile().

The trapped read is the alias target's file, not the function's file. class_alias(\Repro\Target::class, 'File') autoloads Repro\Target, and that read is what the trap records. The target already being loaded is the normal case — and the precondition for success: class_alias() includes nothing, it names a class that is already there. Nothing is re-included and no function is redeclared, so the #14988 hazard is not present.

For Laravel that precondition always holds: larastan's bootstrap boots the application, so Illuminate\Support\Facades\Cache is loaded long before class_alias(..., 'Cache') runs. Which is why the release phar still reports every facade alias on our app (30 class.notFound, measured on 2.2.9).

Suggested narrowing: only the file that declares the function of that name

         if ($locatedFiles === []) {
             return false;
         }
+        // Only re-including the file that *declares the function of this name* redeclares it.
+        // A trapped read of any other file is not the #14988 hazard: an alias autoloader reads
+        // the file of the class it aliases to, and that file being loaded already is the normal
+        // case - class_alias() does not include anything, it names a class that is there.
+        $functionFile = (new ReflectionFunction($className))->getFileName();
+        if ($functionFile === false) {
+            return false;
+        }
         // PHP canonicalises the path before it reaches a stream wrapper - a `/./` segment, a
         // symlinked directory or an include-path-relative name all arrive resolved - so the
         // trapped paths compare directly against get_included_files().
         $includedFiles = get_included_files();
         foreach ($locatedFiles as $locatedFile) {
+            if ($locatedFile !== $functionFile) {
+                continue;
+            }
             if (in_array($locatedFile, $includedFiles, true)) {
                 return true;
             }
         }
         return false;

A builtin has no declaring file (getFileName() === false), so there is nothing it could redeclare.

alias same-named function tip with the patch
File, Hash PHP builtins file(), hash() class.notFound No errors
Cache, Str userland helpers cache(), str() class.notFound No errors
Widget (control) none No errors No errors

Red-team for the hazard the guard exists for — an autoloader that maps the class name to the file that declares the function, with that file already required at bootstrap:

<?php // bootstrap.php
require __DIR__ . '/helpers.php';           // declares function sniffhelper()
spl_autoload_register(static function (string $class): void {
    if ($class === 'sniffhelper') {
        require __DIR__ . '/helpers.php';   // would fatally redeclare
    }
}, true, true);

Still declined with the patch$locatedFile === $functionFile and it is included, so the protection holds and no fatal occurs.

What I did not verify

  • I have not run your test suite or the e2e/ projects against this patch. The evidence above is the matrix and the red-team case only, so please treat the diff as a suggested direction rather than something ready to merge. The e2e/bug-14988, bug-15102, bug-15102b and bug-12972b/c projects are the ones I would expect to be decisive.
  • ReflectionFunction would throw on a name that is not a function; here it sits inside the function_exists($className) branch, but that ordering is load-bearing and worth keeping in mind if the guard is ever called from elsewhere.
  • I could not measure the tip against our real Laravel application: larastan crashes before analysis starts on the current tip, which looks unrelated to this PR — filed separately as 2.2.x tip: larastan crashes before analysis - project stub files are collected before bootstrapFiles run phpstan#15120. The 30-error figure above is from the 2.2.9 release phar.

Happy to open this as a PR with the change plus an e2e project (a prepended class_alias autoloader whose alias name collides with a function), or to leave it with you if you would rather fold it into your own follow-up.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

You are right on every point, and thank you for the depth - the diagnosis, the portable repro and the red-team case all hold. Sent as #6288 with your condition, credited to you.

Confirmed on the merged tip (c10897b1f), fresh tmpDir per run: your repro gives 2x class.notFound, and No errors with the narrowing. Your red-team case stays declined, no fatal.

The part I should have caught myself: e2e/bug-15102b require_once'd the alias target in its bootstrap, so class_alias() read no file, the guard never fired, and the project passed while the real shape failed. It now autoloads the target through Composer the way AliasLoader does, and it fails without the change (2 errors) - so the regression test finally covers what you reported rather than a shape that cannot fail.

I ran the checks you flagged as decisive: e2e/bug-14988, bug-15102, bug-15102b, bug-12972b, bug-12972c all exit 0, full suite 21159, self-analysis clean, phpcs clean. Your ReflectionFunction note is handled the way the sibling AutoloadSourceLocator does it - the two identical entries it already carries in phpstan-baseline.neon for that rule - since the hazard genuinely is a runtime redeclare.

Two things I did not fold in: the guard is only reachable from inside the function_exists($className) branch, so ReflectionFunction cannot throw there, and I left that ordering as a comment rather than a defensive check. And phpstan/phpstan#15120 (larastan crashing on the tip) stays separate - if it turns out to block your 30-facade measurement, say so on that issue and I will look.

@ondrejmirtes

Copy link
Copy Markdown
Member

FYI reverting this: #6292

phpstan/phpstan#15102 keeps working.

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.

3 participants