Skip to content

Do not autoload a class whose name is an already defined function - #6185

Merged
staabm merged 1 commit into
phpstan:2.2.xfrom
SanderMuller:fix-autoload-functions-trap
Aug 6, 2026
Merged

Do not autoload a class whose name is an already defined function#6185
staabm merged 1 commit into
phpstan:2.2.xfrom
SanderMuller:fix-autoload-functions-trap

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note: this squash-merged as commit 8e2efc0ce under an earlier title about a file-read trap. That approach was abandoned; the merged code is the function_exists guard described below. This description is the accurate record of what shipped.

AutoloadFunctionsSourceLocator runs the autoloaders collected from bootstrap files to find a class, without first checking whether the name is already a defined function. When a catch-all bootstrap autoloader is present, it resolves a name like VeeWee\Xml\Dom\Builder\value to the file that defines the function and includes it. That file was already loaded once (veewee/xml ships one function per PSR-4 path and requires it from its files-autoload bootstrap, and value.php is a bare function value()), so the second include fatally redeclares it and the worker dies with Cannot redeclare function VeeWee\Xml\Dom\Builder\value().

The real-world trigger, from @LucasHantz, is PHP_CodeSniffer's autoloader, loaded through bootstrapFiles:

parameters:
    bootstrapFiles:
        - vendor/squizlabs/php_codesniffer/autoload.php

This is the documented way to make PHPCS's classes resolvable, since the package ships no Composer autoload metadata, so it is reached by any project that lints its own sniffs or reports. It reaches the crash because it registers as a string callable (Foo\Autoload::load), which spl_autoload_functions() returns as ['Foo\Autoload', 'load'] so it survives the is_object($fn[0]) && get_class($fn[0]) === Composer\Autoload\ClassLoader::class exclusion in bin/phpstan; it is catch-all (it falls back to Composer's findFile()); and it uses a plain include.

Fix

The name is probed as a class and the file holds a function, so running the autoloader cannot find a class there, it only re-includes the file. Return early when the name is already a defined function, the same way the locator already returns early for an existing class, interface or trait. A class that genuinely exists under that name in another file is still found by the later source locators in the chain.

I first tried wrapping the autoloader call in FileReadTrapStreamWrapper, as AutoloadSourceLocator does. That stops the redeclare, but it is not viable here: these bootstrap autoloaders run for real, and some read a file for its return value (a loader delegating via $loader = include ...; $loader->loadClass(), e.g. Rector's) or write cache files (RobotLoader), which the trap breaks. The e2e suite caught both, so the trap approach is out.

Test

e2e/bug-14988 reproduces it self-contained (so it cannot silently stop reproducing when PHPCS changes): an unguarded one-function-per-file, a bootstrap that requires it, and a catch-all autoloader shaped like PHP_CodeSniffer's (string callable, Composer-findFile fallback, plain include). Before the change the worker fatals with the redeclare; after it, analysis completes.

Follow-up (out of scope here)

The ClassLoader exclusion exists only in bin/phpstan. The second place that collects autoloaders, the bootstrapFiles pass in CommandHelper, has no such exclusion, so a bootstrap file registering a genuine Composer\Autoload\ClassLoader is collected too. That is a separate tidy-up and is not needed for this fix.

Thanks to @LucasHantz for the precise diagnosis and the PHPCS reproduction.

Closes phpstan/phpstan#14988

@staabm

staabm commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Please double check whether this also fixes phpstan/phpstan#14976

@SanderMuller
SanderMuller force-pushed the fix-autoload-functions-trap branch 2 times, most recently from 50ebc81 to 5312948 Compare August 5, 2026 22:18
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Checked, and no, it does not. I ran this PR's build against the #14976 repro (the bug-12972b fixture from #6069, whose bootstrap autoloader throws for \other12972\MyClass) and it still fails with Internal error: this should not happen; #6069's reorder makes that same run clean.

They are separate problems:

So they compose: #6069 for #14976, this for #14988. Happy to fold the reorder in here if you would rather one PR close both, but I did not want to step on #6069.

@staabm

staabm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Thanks for checking. Lets keep it separate

Comment on lines +39 to +47
// If the name is already a defined function, do not run the autoloaders to find a class of
// that name. A catch-all bootstrap autoloader (e.g. PHP_CodeSniffer's, which falls back to
// Composer's findFile()) would resolve it to the function's own file and plain-include it a
// second time - it was loaded once already, e.g. by a package that ships one function per
// PSR-4 path and requires it from its bootstrap - fatally redeclaring the function. The
// class being probed for is not in that file anyway. See https://github.com/phpstan/phpstan/issues/14988
if (function_exists($className)) {
return null;
}

@staabm staabm Aug 6, 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.

does this make sense? php cannot autoload functions and there might exist a same named class and function without problems at the same time, see https://3v4l.org/QkbUH#veol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point that a class and a function can share a name. It does make sense: returning null here is the SourceLocator "I decline, try the next locator" signal, not "no such class", so the class is still resolved by the later locators in the chain.

Your exact example (class Abc {} and function abc() {}, both defined) never reaches this line. The class_exists($className, false) check just above returns null first, so the class is found by runtime reflection as before.

The only state this guard is reached in is: the function is defined but the class is not (they cannot both be in the same already-loaded file, or class_exists would be true). If a class of that name genuinely exists in another file, it is still located by the downstream AutoloadSourceLocator. I checked with a function loaded eagerly plus a class of the same FQN in a separate autoloadable file, and PHPStan resolves new \Coexist\Thing() to Coexist\Thing and its property to int.

What the guard prevents is only this locator running the bootstrap autoloaders untrapped for a name that is already a function, which is what re-includes the function's file and fatals. I reworded the comment to make the "decline, not deny" intent explicit.

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.

please add a separate e2e test which proofs same named function/class still works (when defined in separate files)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. The bug-14988 fixture now includes gadget: a function Redeclare\Builder\gadget loaded via the bootstrap, and a class Redeclare\Builder\gadget in a separate file (classes/gadget.php), both reachable only through the bootstrap autoloader (not Composer). The probe does (new gadget())->size, so if the guard ever hid the class the run would report "class not found" and the e2e would fail. With the fix it stays clean.

@SanderMuller
SanderMuller force-pushed the fix-autoload-functions-trap branch from 5312948 to 49852e5 Compare August 6, 2026 07:13
@SanderMuller
SanderMuller force-pushed the fix-autoload-functions-trap branch from 49852e5 to e5b5d93 Compare August 6, 2026 08:18
@SanderMuller

Copy link
Copy Markdown
Contributor Author

One edge case for the record, non-blocking: if a class is undiscoverable by the file/dir/composer locators and can only be loaded through a delegate autoloader ($loader = include ...; $loader->loadClass()), and its name also collides with an already defined function, the function_exists skip reroutes it to the trapped AutoloadSourceLocator, which errors on that autoloader shape. No real project should hit it, since delegate autoloaders serve composer-autoloadable classes, which resolve earlier and never reach that locator, and putting the class in a scanned directory resolves it cleanly. The trap-based alternative would have broken those same autoloaders for composer classes too, so this is the better of the two shapes. Flagging for awareness.

@SanderMuller
SanderMuller requested a review from staabm August 6, 2026 09:05
AutoloadFunctionsSourceLocator runs the autoloaders collected from bootstrap
files to find a class, without first checking whether the name is already a
defined function. A catch-all bootstrap autoloader then resolves a name like
VeeWee\Xml\Dom\Builder\value to the file that defines the function and
plain-includes it. That file was already loaded once - veewee/xml ships one
function per PSR-4 path and requires it from its files-autoload bootstrap - so
the second include fatally redeclares the function and the worker dies with
"Cannot redeclare function ...".

The common real-world autoloader here is PHP_CodeSniffer's, loaded via
bootstrapFiles because the package ships no Composer autoload metadata; it falls
back to Composer's findFile() for any name and includes with a plain include.

The name is probed as a class and the file holds a function, so running the
autoloader cannot find a class there; it only re-includes the file. Return early
when the name is already a defined function, the same way the locator already
returns early for an existing class, interface or trait. A class that genuinely
exists under that name in another file is still found by the later source
locators in the chain.

Wrapping the autoloader call in FileReadTrapStreamWrapper (as AutoloadSourceLocator
does) was considered but is not viable here: these bootstrap autoloaders run for
real, and some read a file for its return value (a loader delegating via
"$loader = include ...; $loader->loadClass()") or write cache files, which the
trap breaks.

e2e/bug-14988 reproduces it: an unguarded one-function-per-file, a bootstrap that
requires it, and a catch-all autoloader shaped like PHP_CodeSniffer's. Before the
change the worker fatals with the redeclare; after it, analysis completes. It also
covers a class and a function that share a name in separate files (both reachable
only through that bootstrap autoloader, not Composer), asserting the class still
resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@staabm
staabm force-pushed the fix-autoload-functions-trap branch from e5b5d93 to 48a7bbe Compare August 6, 2026 09:25
@staabm
staabm requested a review from VincentLanglet August 6, 2026 09:44
@staabm
staabm merged commit 8e2efc0 into phpstan:2.2.x Aug 6, 2026
755 of 756 checks passed
@staabm

staabm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

thank you @SanderMuller

@staabm

staabm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

damn, I/we should have updated the PR title/commit-message, as we changed the approach

@SanderMuller

Copy link
Copy Markdown
Contributor Author

damn, I/we should have updated the PR title/commit-message, as we changed the approach

Right, my bad. Will update the guidelines for my AI to keep this in mind for the future

@SanderMuller SanderMuller changed the title Run bootstrap-registered autoloaders inside the file-read trap Do not autoload a class whose name is an already defined function Aug 6, 2026
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.

Cannot redeclare function: AutoloadFunctionsSourceLocator includes PSR-4 function files untrapped

2 participants