Repository navigation
2.2.9: autoloader that replaces Composer ClassLoader is demoted below static source locators #15102
Description
Bug report
Since 2.2.9, a bootstrap-registered autoloader that replaces Composer's ClassLoader in the spl_autoload queue is always bucketed as appended, so it is consulted only after the static source locators. Up to 2.2.8 it was consulted first, which matched the runtime order.
This is a regression from phpstan/phpstan-src#6069 ("Consult bootstrap-registered custom autoloaders only after the static source locators", commit f896750e). git tag --contains f896750e lists 2.2.9 only.
The intent of #6069 is right — mirror the runtime order instead of always consulting custom autoloaders first. The problem is the Composer-detection heuristic in collectNewAutoloadFunctions() (src/autoloadFunctions.php):
foreach ($autoloadFunctionsAfter as $index => $after) {
if (
is_array($after) && count($after) > 0
&& is_object($after[0])
&& get_class($after[0]) === ClassLoader::class
) {
$composerIndex = $index;
break;
}
}
// ...
if ($composerIndex !== null && $index < $composerIndex) {
$prepended[] = $after;
} else {
$appended[] = $after; // <- everything lands here when $composerIndex === null
}If no Composer\Autoload\ClassLoader instance remains in the queue, $composerIndex stays null, and the else branch sends every custom autoloader to appended — including one that has taken Composer's place and therefore has runtime priority equal to, or ahead of, Composer's. There is no branch for "a custom loader replaced Composer's ClassLoader".
A concrete loader that does this
typo3/class-alias-loader removes Composer's callable and registers itself in its stead, delegating internally to the wrapped ClassLoader:
public function register(bool $prepend = false): void
{
spl_autoload_unregister([$this->composerClassLoader, 'loadClass']);
spl_autoload_register([$this, 'loadClassWithAlias'], true, $prepend);
}The spl_autoload queue after require vendor/autoload.php in any TYPO3 installation:
0: TYPO3\ClassAliasLoader\ClassAliasLoader::loadClassWithAlias
1: PHPStan\PharAutoloader::loadClass
No Composer\Autoload\ClassLoader entry exists to be found, so the detection cannot succeed by construction.
Observed effect
In TYPO3 v14, TYPO3\CMS\Extbase\Annotation\Validate exists only as a runtime class_alias to TYPO3\CMS\Extbase\Attribute\Validate, which carries #[\Attribute(...)]. An IDE-only stub file — guarded by die() and never loaded at runtime — additionally declares the same FQCN as a plain subclass, so that IDEs can resolve the deprecated name.
Up to 2.2.8 the alias loader resolved the name to the real attribute class and analysis was clean. Since 2.2.9 the OptimizedDirectorySourceLocator over the scanned source paths wins and returns the stub instead. Because PHP does not inherit #[Attribute] to subclasses, PHPStan then reports:
Class TYPO3\CMS\Extbase\Annotation\Validate is not an Attribute class. [attribute.notAttribute]
Verified by bisect against an unchanged checkout, with only phpstan/phpstan moved and everything else held at the same state:
| phpstan | result |
|---|---|
| 2.2.7 | clean |
| 2.2.8 | clean |
| 2.2.9 | 3× attribute.notAttribute |
AttributesCheck and ClassReflection::findAttributeFlags() are byte-identical between 2.2.7 and 2.2.9, so the rule is behaving correctly — it is simply handed the wrong file. Removing the stub's directory from the scanned paths also makes the error disappear, which confirms the locator ordering as the cause rather than the rule.
Reproduction
Not reproducible on phpstan.org/try, since it depends on autoloader registration in a bootstrap file rather than on type inference. The minimal shape is:
- A bootstrap file (
parameters.bootstrapFiles) that unregisters[$composerLoader, 'loadClass']and registers a wrapper which resolves some FQCN viaclass_alias()and otherwise delegates to the wrapped loader. - A file inside
parameters.pathsthat declares the same FQCN differently (for the symptom above: as a subclass of an#[\Attribute]class rather than as the attribute class itself).
On 2.2.8 the alias wins; on 2.2.9 the file in paths wins.
I am happy to put together a self-contained reproduction repository if that would help — I did not want to open the issue with only a large framework checkout as evidence.
Expected output
The custom autoloader should be treated as prepended when it has replaced Composer's ClassLoader, so that it is consulted before the static source locators — as it was up to 2.2.8, and as PHP does at runtime.
A minimal fix would be to make the fallback reflect that case: when $composerIndex === null, no Composer ClassLoader is present in the queue, which means a custom loader has taken over its role. Bucketing those loaders as prepended rather than appended restores the runtime order for this shape, while keeping #6069's behaviour intact whenever Composer's own loader is still registered.
Did PHPStan help you today? Did it make you happy in any way?
Very much — PHPStan runs on every TYPO3 Core patch and in our nightly pipelines, and it routinely catches things review would not. Thank you for the continued work on it, and apologies for arriving with an autoloader edge case rather than a nice playground link.
Activity
mergeable commentedon Aug 23, 2026
This bug report is missing a link to reproduction at phpstan.org/try.
It will most likely be closed after manual review.
[TASK] Exclude IDE class alias stubs from PHPStan
ondrejmirtes commentedon Aug 24, 2026
Hi, I'm not really surprised by this. Every time BetterReflectionSourceLocatorFactory is touched, and especially when the order of locators is changed, we might fix one use-case, but at the same time break different use-cases.
This was touched by phpstan/phpstan-src#6069 and also by phpstan/phpstan-src#6185.
So we fixed #14976 and #14988 (which might have been broken by phpstan/phpstan-src#6069 but I'd have to verify that) but broke probably fairly popular
typo3/class-alias-loaderwhich used to work for years./cc @staabm @SanderMuller
I don't know if it's possible to support all use-cases at once because they might be the polar opposites of each other. I'd prefer if we fixed things that used to work for years and even reopen #14976 which was opened pretty recently.
SanderMuller commentedon Aug 24, 2026
I dug into this. Short version: @sbuerk's diagnosis is exactly right, the fix he proposes is confined to a branch that no other use-case reaches, and this shape is invisible to our current e2e suite - which is why it got through.
Scope first, since I was cc'd: phpstan/phpstan-src#6185 only changed
AutoloadFunctionsSourceLocatorso it does not autoload a name that is already a defined function; it did not touch the bucketing or the locator order. The ordering heuristic is phpstan/phpstan-src#6069 alone, so reverting #6185 would not affect this.The bucketing, at unit level. Feeding
collectNewAutoloadFunctions()the queue from the issue -[[$aliasLoader, 'loadClassWithAlias'], ['PHPStan\PharAutoloader', 'loadClass']]:$composerIndex === nullfallbackSo the alias loader is currently consulted after the static locators, exactly as described.
This is phar-only, which matters for reproducing it. I built a self-contained reproduction - a bootstrap that unregisters
[$composerLoader, 'loadClass']and registers a wrapper doingclass_alias(), plus a conflicting IDE-stub insidepaths- and it does reproduceattribute.notAttributeon 2.2.x. But the fallback fix changes nothing there, because a source checkout always has PHPStan's ownComposer\Autoload\ClassLoaderregistered, so$composerIndexis0and the custom loader is appended for the ordinary reason.$composerIndex === nullonly happens when PHPStan contributesPharAutoloaderinstead - i.e. under the phar, which is what @sbuerk is running. Consequence:e2e/*cases cannot cover this, since they all run../../bin/phpstanfrom source. Any fix here needs the unit test, and ideally a phar-based e2e.What the fix costs. Exactly one existing assertion, and it is the one that pins today's behaviour:
Both Composer-present orderings keep passing, and the e2e cases that pin the use-cases we fixed stay green with the fallback in place:
bug-12972bandbug-12972c(#6069, #14976) andbug-14988(#6185, #14988) all exit 0 with no errors.So on the question of whether these use-cases are polar opposites: for this one they are not. The fallback only decides what happens when no
Composer\Autoload\ClassLoaderremains in the queue, which is a state #14976 and #14988 never reach - in both of those Composer's own loader is still registered, and the$index < $composerIndexcomparison keeps doing the work. Restoring 2.2.8 ordering for the replaced-loader case looks safe on the evidence I have, and I would rather do that than reopen #14976.Happy to open the PR with the fallback, the flipped unit test, and a
collectNewAutoloadFunctions()case for the replaced-loader queue - say the word. @sbuerk, no need for the reproduction repo unless you want it recorded; the unit-level shape above is enough to pin it, though a phar e2e would be the only end-to-end guard.SanderMuller commentedon Aug 24, 2026
One thing from @ondrejmirtes's comment I left unanswered: whether #14988 "might have been broken by #6069". It was not - the two are independent, on three separate grounds.
Dates. Cannot redeclare function: AutoloadFunctionsSourceLocator includes PSR-4 function files untrapped #14988 was reported 2026-07-23. How long do you support 0.12.x verison? #6069 was merged 2026-08-07, fifteen days later, so no released version carrying How long do you support 0.12.x verison? #6069 existed when the bug was reported.
Ancestry. How long do you support 0.12.x verison? #6069's merge commit
f896750e0has8e2efc0ceas its parent - and8e2efc0ceis Inheriting phpdoc from interface not working as expected #6185. Inheriting phpdoc from interface not working as expected #6185 landed first (2026-08-06), How long do you support 0.12.x verison? #6069 was merged on top of it a day later.Experiment. The
e2e/bug-14988project reproduces at8e2efc0ce^(b88ca9ccc), a tree that does not contain How long do you support 0.12.x verison? #6069 at all:and reports no errors at Inheriting phpdoc from interface not working as expected #6185's merge commit.
So reverting one does not undo the other's fix, and #6069's heuristic is the only ordering change in play here.
While checking that, one detail worth flagging for anyone reading the history: #6185's merged diff in
src/is a single guard -- in
AutoloadFunctionsSourceLocator. Its commit subject, "Run bootstrap-registered autoloaders inside the file-read trap", is left over from an earlier revision of that PR and does not describe what was merged; the trap was already in place. Nothing in it touches the bucketing or the locator order, socollectNewAutoloadFunctions()is entirely #6069's.hoetaek commentedon Aug 25, 2026
We hit what appears to be another shape of this regression, with a different autoloader: Laravel's facade alias loader.
Setup: Laravel 13 app analysed with larastan 3.10.0. Larastan's bootstrap (
vendor/larastan/larastan/bootstrap.php) boots the application, and Laravel'sRegisterFacadesbootstrapper registersIlluminate\Foundation\AliasLoader— an spl_autoload callback (prepended) that lazilyclass_alias()es root-level facade aliases (Cache,Str,Hash, …).Observed:
use Cache;…Cache::remember()) reportsunknown class Cache(class.notFound) — 30 findings in our app. No other changes between runs; bisected by downgrading phpstan only.class_exists('Cache')is true andAliasLoader::getInstance()->getAliases()lists 45 aliases.This is consistent with the 2.2.9 changes phpstan-src#6069 (bootstrap-registered autoloaders consulted only after the static source locators) and possibly phpstan-src#6185 (autoloaders run inside the file-read trap —
class_alias()performs no file read, so the trap may have nothing to locate).Happy to provide a minimal repro repo if useful.
staabm commentedon Aug 25, 2026
@hoetaek please provide a minimal reproduction
@SanderMuller plesse provide a fix and additional e2e test coverage
hoetaek commentedon Aug 25, 2026
@staabm Here is the minimal reproduction — and while reducing it, the mechanism for our case turned out to be not the #6069 ordering: it reproduces with a plain prepended closure while Composer's
ClassLoaderstays registered in the queue, and it is name-dependent.Minimal repro (fully portable — no Laravel, no larastan):
Instantiated class File not found.(class.notFound)[OK] No errorsThe trigger is the alias name colliding with an existing function (case-insensitive):
function_exists(name)in the analysing processFile,Hashfile(),hash()Cache,Str(in a Laravel app)cache(),str()Alias,Node,Type,CollectionControl experiment: defining the alias eagerly in the bootstrap (
class_alias(...)executed at bootstrap time, no autoloader involved) makes 2.2.9 pass — the class is fine once it exists; only the bootstrap-autoloader lookup path is blocked for these names.This matches the #6185 guard quoted above —
if (function_exists($className)) { return null; }inAutoloadFunctionsSourceLocator— which skips class lookups whenever a same-named function exists, although the two live in separate symbol spaces (class Fileandfile()legally coexist).So there appear to be two independent 2.2.9 regressions in this area: the replaced-
ClassLoaderordering (#6069 — this issue's original report) and the function-name-collision guard (#6185 — our Laravel case). Happy to move ours to a separate issue if you prefer.Why this hits Laravel/larastan users broadly: larastan's bootstrap boots the app, and root facade aliases (
use Cache;) are resolved byIlluminate\Foundation\AliasLoader, a prependedclass_aliasautoloader. Laravel also ships global helpers (cache(),str(),route(),view(),config(),app(), …) and PHP hasfile()/hash()— so most of the default alias names hit the guard and reportclass.notFound.Workaround until fixed: register the aliases eagerly in a bootstrap file instead of relying on lazy autoloading.
SanderMuller commentedon Aug 25, 2026
@staabm on the fix: phpstan/phpstan-src#6265 for @hoetaek's half, with a regression test. @hoetaek's reduction is right, and it is my #6185 guard - not the ordering.
Confirmed against the release phars on their repro, with a second alias name added as control:
File(collides withfile())Alias(no such function)What #6265 changes. The hazard behind #14988 was never the name: a catch-all autoloader resolved a class name to a function's file and included it a second time, redeclaring the function. So instead of declining whenever
function_exists($className), the locator now probes the autoloaders underFileReadTrapStreamWrapper- which reports the file they would read without executing it - and declines only if that file is already inget_included_files(). Loaders that define the class without reading a file (class_alias(),eval()) work again, ande2e/bug-14988still exits 0, so #14988 stays fixed rather than being traded away.On the e2e coverage you asked for, one caveat I would rather state than paper over: an
e2e/*project cannot pin either half of this issue. They run../../bin/phpstanfrom source, and a source run has PHPStan's ownComposer\Autoload\ClassLoaderin the queue - which is what makes$composerIndexnon-null (hiding the ordering half) and what rescues the aliased class further down the chain (hiding the guard half). Both reproduce only under the phar. So #6265's test is at unit level, exercising the locator directly. If you want end-to-end protection for this area, it needs a phar-based lane; I am happy to look at what that would take as a separate piece of work.Still open: the ordering half - the original report,
typo3/class-alias-loader. That is the$composerIndex === nullfallback incollectNewAutoloadFunctions()I measured earlier: it flips exactly one existing assertion, and #14976 and #14988 keep their coverage green. Say the word and I will send it as a second PR - separate from #6265, since the two regressions are independent and I would rather they be revertable independently.@hoetaek no need to move yours to a separate issue from my side unless @ondrejmirtes prefers it - the two mechanisms are now clearly separated, and thank you for the reduction: the name-dependence table is what identified the cause.
hoetaek commentedon Aug 25, 2026
Thanks for the quick fix, @SanderMuller — #6265's approach reads right for our case: the
AliasLoaderpath defines the class without reading any file, so a decline gated on "would re-include an already-loaded file" leaves it free to resolve, while still covering the #14988 redeclare hazard. We will re-verify against our Laravel app once it lands in a release and report back here.Decline a bootstrap autoloader only when it would re-include a loaded…
Decline a bootstrap autoloader only when it would re-include a loaded…
Decline a bootstrap autoloader only when it would re-include a loaded…
Decline a bootstrap autoloader only when it would re-include a loaded…
Decline a bootstrap autoloader only when it would re-include a loaded…
Decline a bootstrap autoloader only when it would re-include a loaded…
SanderMuller commentedon Aug 26, 2026
The ordering half is now up as well: phpstan/phpstan-src#6281, implementing @sbuerk's proposed fallback (
$composerIndex === null-> prepended). #6265 covers @hoetaek's half.Verified: 2 of the 6
CollectNewAutoloadFunctionsTestcases fail without the change,e2e/bug-12972b/bug-12972c(#6069's own ordering coverage) ande2e/bug-14988exit 0, full suite 21156 green. With Composer's loader still in the queue the split is unchanged, so ordinary projects are untouched.@sbuerk if you can run either branch against the TYPO3 v14 checkout that produced the three
attribute.notAttributeerrors, that would confirm it end to end - a source run will not reproduce the original symptom, because PHPStan's own ComposerClassLoaderis then in the queue and the fallback never fires. It needs a phar build, orbin/phpstanfrom the branch with your bootstrap.Decline a bootstrap autoloader only when it would re-include a loaded…
ondrejmirtes commentedon Aug 26, 2026
Fixed phpstan/phpstan-src#6281 + phpstan/phpstan-src#6265
Resolve bootstrap autoloaders' classes from the files they ask for, r…
Add TYPO3 core to the integration tests
github-actions commentedon Sep 27, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.