Skip to content

2.2.9: autoloader that replaces Composer ClassLoader is demoted below static source locators #15102

Closed

Description

@sbuerk

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:

  1. A bootstrap file (parameters.bootstrapFiles) that unregisters [$composerLoader, 'loadClass'] and registers a wrapper which resolves some FQCN via class_alias() and otherwise delegates to the wrapped loader.
  2. A file inside parameters.paths that 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

  1. mergeable commented on Aug 23, 2026

    @mergeable

    This bug report is missing a link to reproduction at phpstan.org/try.

    It will most likely be closed after manual review.

  2. ondrejmirtes commented on Aug 24, 2026

    @ondrejmirtes
    Member

    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-loader which 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.

  3. SanderMuller commented on Aug 24, 2026

    @SanderMuller
    Contributor

    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 AutoloadFunctionsSourceLocator so 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']]:

    prepended appended
    2.2.x today 0 1
    with the $composerIndex === null fallback 1 0

    So 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 doing class_alias(), plus a conflicting IDE-stub inside paths - and it does reproduce attribute.notAttribute on 2.2.x. But the fallback fix changes nothing there, because a source checkout always has PHPStan's own Composer\Autoload\ClassLoader registered, so $composerIndex is 0 and the custom loader is appended for the ordinary reason. $composerIndex === null only happens when PHPStan contributes PharAutoloader instead - i.e. under the phar, which is what @sbuerk is running. Consequence: e2e/* cases cannot cover this, since they all run ../../bin/phpstan from 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:

    ✔ False inputs yield empty result
    ✔ Autoloaders are split by composer position
    ✘ Without composer everything is appended        <- would need to flip
    ✔ Composer and phar autoloader and preexisting are excluded
    ✔ Preexisting autoloader before composer is not reported
    

    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-12972b and bug-12972c (#6069, #14976) and bug-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\ClassLoader remains 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 < $composerIndex comparison 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.

  4. SanderMuller commented on Aug 24, 2026

    @SanderMuller
    Contributor

    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.

    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 -

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

    - 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, so collectNewAutoloadFunctions() is entirely #6069's.

  5. hoetaek commented on Aug 25, 2026

    @hoetaek

    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's RegisterFacades bootstrapper registers Illuminate\Foundation\AliasLoader — an spl_autoload callback (prepended) that lazily class_alias()es root-level facade aliases (Cache, Str, Hash, …).

    Observed:

    • 2.2.8: clean run.
    • 2.2.9: every use of a root alias (use Cache; … Cache::remember()) reports unknown class Cache (class.notFound) — 30 findings in our app. No other changes between runs; bisected by downgrading phpstan only.
    • Runtime is unaffected: after the same boot, class_exists('Cache') is true and AliasLoader::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.

  6. staabm commented on Aug 25, 2026

    @staabm
    Contributor

    @hoetaek please provide a minimal reproduction

    @SanderMuller plesse provide a fix and additional e2e test coverage

  7. hoetaek commented on Aug 25, 2026

    @hoetaek

    @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 ClassLoader stays registered in the queue, and it is name-dependent.

    Minimal repro (fully portable — no Laravel, no larastan):

    // composer.json
    {
        "require-dev": { "phpstan/phpstan": "2.2.9" },
        "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();
        }
    }
    • 2.2.9: Instantiated class File not found. (class.notFound)
    • 2.2.8: [OK] No errors

    The trigger is the alias name colliding with an existing function (case-insensitive):

    alias name function_exists(name) in the analysing process result
    File, Hash true — PHP built-ins file(), hash() ✗ unknown class
    Cache, Str (in a Laravel app) true — Illuminate helpers cache(), str() ✗ unknown class
    Alias, Node, Type, Collection false ✓ resolved

    Control 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; } in AutoloadFunctionsSourceLocator — which skips class lookups whenever a same-named function exists, although the two live in separate symbol spaces (class File and file() legally coexist).

    So there appear to be two independent 2.2.9 regressions in this area: the replaced-ClassLoader ordering (#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 by Illuminate\Foundation\AliasLoader, a prepended class_alias autoloader. Laravel also ships global helpers (cache(), str(), route(), view(), config(), app(), …) and PHP has file()/hash() — so most of the default alias names hit the guard and report class.notFound.

    Workaround until fixed: register the aliases eagerly in a bootstrap file instead of relying on lazy autoloading.

  8. SanderMuller commented on Aug 25, 2026

    @SanderMuller
    Contributor

    @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 with file()) Alias (no such function)
    2.2.8 resolved resolved
    2.2.9 class.notFound resolved

    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 under FileReadTrapStreamWrapper - which reports the file they would read without executing it - and declines only if that file is already in get_included_files(). Loaders that define the class without reading a file (class_alias(), eval()) work again, and e2e/bug-14988 still 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/phpstan from source, and a source run has PHPStan's own Composer\Autoload\ClassLoader in the queue - which is what makes $composerIndex non-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 === null fallback in collectNewAutoloadFunctions() 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.

  9. hoetaek commented on Aug 25, 2026

    @hoetaek

    Thanks for the quick fix, @SanderMuller — #6265's approach reads right for our case: the AliasLoader path 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.

  10. SanderMuller commented on Aug 26, 2026

    @SanderMuller
    Contributor

    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 CollectNewAutoloadFunctionsTest cases fail without the change, e2e/bug-12972b/bug-12972c (#6069's own ordering coverage) and e2e/bug-14988 exit 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.notAttribute errors, that would confirm it end to end - a source run will not reproduce the original symptom, because PHPStan's own Composer ClassLoader is then in the queue and the fallback never fires. It needs a phar build, or bin/phpstan from the branch with your bootstrap.

  11. ondrejmirtes commented on Aug 26, 2026

    @ondrejmirtes
    Member
  12. github-actions commented on Sep 27, 2026

    @github-actions

    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.

  13. locked as resolved and limited conversation to collaborators on Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions