From 0e6950a0a4abfb96516f1e0ae35d287453348970 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:42:42 +0000 Subject: [PATCH 1/7] Consult bootstrap-registered custom autoloaders only after the static source locators - Move `AutoloadFunctionsSourceLocator` in `BetterReflectionSourceLocatorFactory` from the front of the locator chain to just before the runtime `AutoloadSourceLocator`, so it runs only as a fallback after the analysed-file, Composer class-map/PSR-4 and PHP-internal locators. - Previously the custom autoloaders registered by bootstrap files (stored in `__phpstanAutoloadFunctions`, from which Composer's own ClassLoader is deliberately excluded) were invoked eagerly for every class name, even for classes that Composer resolves statically at runtime. Because Composer's loader is registered first at runtime, those custom autoloaders are never reached for such classes - PHPStan invoking them anyway ran code paths that cannot happen at runtime (e.g. an autoloader that throws for a class Composer already provides). - Classes that only a custom autoloader can produce (e.g. `eval`'d classes) are still handled, since the locator remains in the chain as a last resort. - Adds e2e test `e2e/bug-12972b` reproducing the reported crash and wires it into the e2e workflow. --- .github/workflows/e2e-tests.yml | 4 ++++ e2e/bug-12972b/.gitignore | 2 ++ e2e/bug-12972b/autoloader.php | 9 ++++++++ e2e/bug-12972b/composer.json | 7 ++++++ e2e/bug-12972b/phpstan.dist.neon | 8 +++++++ e2e/bug-12972b/real-world.php | 6 +++++ e2e/bug-12972b/src/folder/file2.php | 9 ++++++++ e2e/bug-12972b/src/other/file.php | 10 +++++++++ .../BetterReflectionSourceLocatorFactory.php | 22 +++++++++++++------ 9 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 e2e/bug-12972b/.gitignore create mode 100644 e2e/bug-12972b/autoloader.php create mode 100644 e2e/bug-12972b/composer.json create mode 100644 e2e/bug-12972b/phpstan.dist.neon create mode 100644 e2e/bug-12972b/real-world.php create mode 100644 e2e/bug-12972b/src/folder/file2.php create mode 100644 e2e/bug-12972b/src/other/file.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 11504b44489..4c275ca6b32 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -132,6 +132,10 @@ jobs: cd e2e/bug-14988 composer install ../../bin/phpstan analyse + - script: | + cd e2e/bug-12972b + composer install + ../../bin/phpstan analyze - script: | cd e2e/bug-14724 composer install diff --git a/e2e/bug-12972b/.gitignore b/e2e/bug-12972b/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/bug-12972b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/bug-12972b/autoloader.php b/e2e/bug-12972b/autoloader.php new file mode 100644 index 00000000000..aaaae3ef08f --- /dev/null +++ b/e2e/bug-12972b/autoloader.php @@ -0,0 +1,9 @@ +doBar(new \other12972\MyClass()); diff --git a/e2e/bug-12972b/src/folder/file2.php b/e2e/bug-12972b/src/folder/file2.php new file mode 100644 index 00000000000..d81e18c01b5 --- /dev/null +++ b/e2e/bug-12972b/src/folder/file2.php @@ -0,0 +1,9 @@ +parser); - $locators[] = new AutoloadFunctionsSourceLocator( - new AutoloadSourceLocator($this->fileNodesFetcher, false), - new ReflectionClassSourceLocator( - $astLocator, - $this->reflectionSourceStubber, - ), - ); $analysedDirectories = []; $analysedFiles = []; @@ -184,6 +177,21 @@ public function create(): SourceLocator $this->phpVersion, )); + // Custom autoloaders registered by bootstrap files are consulted only + // as a fallback, after the static locators above (analysed files, + // Composer class map/PSR-4, PHP internals). At runtime Composer's + // class loader resolves such classes first, so their custom autoloaders + // are never invoked for them - invoking them eagerly here would run + // code paths that cannot happen at runtime. They remain useful for + // classes that only a custom autoloader can produce (e.g. eval'd). + $locators[] = new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($this->fileNodesFetcher, false), + new ReflectionClassSourceLocator( + $astLocator, + $this->reflectionSourceStubber, + ), + ); + $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); $locators[] = new PhpVersionBlacklistSourceLocator(new PhpInternalSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); $locators[] = new PhpVersionBlacklistSourceLocator(new EvaledCodeSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); From 15b3d4296163413a1e5d73f16d2501e50eb472cb Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 20 Jul 2026 16:55:48 +0200 Subject: [PATCH 2/7] move related lines in on block --- .../BetterReflection/BetterReflectionSourceLocatorFactory.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php index 1f5abf8c15e..afa7f21b0c7 100644 --- a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php +++ b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php @@ -97,8 +97,6 @@ public function create(): SourceLocator $locators[] = $this->optimizedSingleFileSourceLocatorRepository->getOrCreate($this->singleReflectionFile); } - $astLocator = new Locator($this->parser); - $analysedDirectories = []; $analysedFiles = []; @@ -184,6 +182,7 @@ public function create(): SourceLocator // are never invoked for them - invoking them eagerly here would run // code paths that cannot happen at runtime. They remain useful for // classes that only a custom autoloader can produce (e.g. eval'd). + $astLocator = new Locator($this->parser); $locators[] = new AutoloadFunctionsSourceLocator( new AutoloadSourceLocator($this->fileNodesFetcher, false), new ReflectionClassSourceLocator( From c9ba64d3367f2bfa8efc5887be4ab4cf055223b9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 20 Jul 2026 15:33:05 +0000 Subject: [PATCH 3/7] Consult bootstrap autoloaders before/after Composer based on registration order Bootstrap-registered custom autoloaders were all consulted after the static source locators. That mirrors runtime only for autoloaders registered after Composer's class loader; one registered before it (e.g. spl_autoload_register($fn, true, true)) runs before Composer at runtime and must be consulted before the static Composer locators too. Detect each autoloader's position relative to Composer's ClassLoader in the spl_autoload queue and split them into a prepended and an appended group. The prepended group is consulted before the static locators (front of the chain), the appended group only as a fallback after them. Co-Authored-By: Claude Opus 4.8 --- bin/phpstan | 34 ++----- src/Command/CommandHelper.php | 23 +++-- .../BetterReflectionSourceLocatorFactory.php | 30 ++++-- .../AutoloadFunctionsSourceLocator.php | 11 ++- src/autoloadFunctions.php | 85 +++++++++++++++++ .../CollectNewAutoloadFunctionsTest.php | 92 +++++++++++++++++++ 6 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 tests/PHPStan/CollectNewAutoloadFunctionsTest.php diff --git a/bin/phpstan b/bin/phpstan index 217a147ac1b..15e67dce78d 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -91,31 +91,15 @@ use Symfony\Component\Console\Helper\ProgressBar; $autoloadFunctionsAfter = spl_autoload_functions(); if ($autoloadFunctionsBefore !== false && $autoloadFunctionsAfter !== false) { - $newAutoloadFunctions = []; - foreach ($autoloadFunctionsAfter as $after) { - if ( - is_array($after) - && count($after) > 0 - ) { - if (is_object($after[0]) - && get_class($after[0]) === \Composer\Autoload\ClassLoader::class - ) { - continue; - } - if ($after[0] === 'PHPStan\\PharAutoloader') { - continue; - } - } - foreach ($autoloadFunctionsBefore as $before) { - if ($after === $before) { - continue 2; - } - } - - $newAutoloadFunctions[] = $after; - } - - $GLOBALS['__phpstanAutoloadFunctions'] = $newAutoloadFunctions; + $collectedAutoloadFunctions = \PHPStan\collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter); + $GLOBALS['__phpstanAutoloadFunctions'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctions'] ?? [], + $collectedAutoloadFunctions['appended'], + ); + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? [], + $collectedAutoloadFunctions['prepended'], + ); } $devOrPharLoader->register(true); diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index aa8a2a2210c..30cd4561dd2 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -41,6 +41,7 @@ use function array_filter; use function array_key_exists; use function array_map; +use function array_merge; use function array_values; use function class_exists; use function count; @@ -56,6 +57,7 @@ use function is_file; use function is_readable; use function is_string; +use function PHPStan\collectNewAutoloadFunctions; use function register_shutdown_function; use function spl_autoload_functions; use function sprintf; @@ -526,18 +528,15 @@ public static function begin( $autoloadFunctionsAfter = spl_autoload_functions(); if ($autoloadFunctionsBefore !== false && $autoloadFunctionsAfter !== false) { - $newAutoloadFunctions = $GLOBALS['__phpstanAutoloadFunctions'] ?? []; - foreach ($autoloadFunctionsAfter as $after) { - foreach ($autoloadFunctionsBefore as $before) { - if ($after === $before) { - continue 2; - } - } - - $newAutoloadFunctions[] = $after; - } - - $GLOBALS['__phpstanAutoloadFunctions'] = $newAutoloadFunctions; + $collectedAutoloadFunctions = collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter); + $GLOBALS['__phpstanAutoloadFunctions'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctions'] ?? [], + $collectedAutoloadFunctions['appended'], + ); + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? [], + $collectedAutoloadFunctions['prepended'], + ); } if (PHP_VERSION_ID >= 80000) { diff --git a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php index afa7f21b0c7..2a97a8e4efd 100644 --- a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php +++ b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php @@ -97,6 +97,21 @@ public function create(): SourceLocator $locators[] = $this->optimizedSingleFileSourceLocatorRepository->getOrCreate($this->singleReflectionFile); } + $astLocator = new Locator($this->parser); + + // Custom autoloaders that bootstrap files registered *before* Composer's + // class loader (e.g. spl_autoload_register($fn, true, true)) run before + // Composer resolves the class at runtime, so they are consulted before + // the static source locators below - mirroring that runtime order. + $locators[] = new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($this->fileNodesFetcher, false), + new ReflectionClassSourceLocator( + $astLocator, + $this->reflectionSourceStubber, + ), + true, + ); + $analysedDirectories = []; $analysedFiles = []; @@ -175,14 +190,13 @@ public function create(): SourceLocator $this->phpVersion, )); - // Custom autoloaders registered by bootstrap files are consulted only - // as a fallback, after the static locators above (analysed files, - // Composer class map/PSR-4, PHP internals). At runtime Composer's - // class loader resolves such classes first, so their custom autoloaders - // are never invoked for them - invoking them eagerly here would run - // code paths that cannot happen at runtime. They remain useful for - // classes that only a custom autoloader can produce (e.g. eval'd). - $astLocator = new Locator($this->parser); + // Custom autoloaders registered *after* Composer's class loader are + // consulted only as a fallback, after the static locators above + // (analysed files, Composer class map/PSR-4, PHP internals). At runtime + // Composer's class loader resolves such classes first, so these custom + // autoloaders are never invoked for them - invoking them eagerly here + // would run code paths that cannot happen at runtime. They remain useful + // for classes that only a custom autoloader can produce (e.g. eval'd). $locators[] = new AutoloadFunctionsSourceLocator( new AutoloadSourceLocator($this->fileNodesFetcher, false), new ReflectionClassSourceLocator( diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php index 750c0a175b8..ad7a867cfbc 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -12,14 +12,21 @@ use function function_exists; use function interface_exists; use function PHPStan\autoloadFunctions; +use function PHPStan\autoloadFunctionsPrependedToComposer; use function trait_exists; final class AutoloadFunctionsSourceLocator implements SourceLocator { + /** + * @param bool $prependedToComposer When true, consult only the autoloaders + * registered before Composer's class loader (prepended); otherwise consult + * the ones registered after it (appended). + */ public function __construct( private AutoloadSourceLocator $autoloadSourceLocator, private ReflectionClassSourceLocator $reflectionClassSourceLocator, + private bool $prependedToComposer = false, ) { } @@ -48,7 +55,9 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): return null; } - $autoloadFunctions = autoloadFunctions(); + $autoloadFunctions = $this->prependedToComposer + ? autoloadFunctionsPrependedToComposer() + : autoloadFunctions(); foreach ($autoloadFunctions as $autoloadFunction) { $autoloadFunction($className); $reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier); diff --git a/src/autoloadFunctions.php b/src/autoloadFunctions.php index 3239fe6aff6..617d7f7ee53 100644 --- a/src/autoloadFunctions.php +++ b/src/autoloadFunctions.php @@ -2,10 +2,95 @@ namespace PHPStan; +use Composer\Autoload\ClassLoader; +use function count; +use function get_class; +use function is_array; +use function is_object; + /** + * Autoloaders that were registered *after* Composer's class loader in the + * spl_autoload queue. At runtime Composer resolves the class first, so these + * are consulted only as a fallback, after the static source locators. + * * @return array */ function autoloadFunctions(): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found { return $GLOBALS['__phpstanAutoloadFunctions'] ?? []; } + +/** + * Autoloaders that were registered *before* Composer's class loader in the + * spl_autoload queue (e.g. spl_autoload_register($fn, true, true)). At runtime + * these run before Composer resolves the class, so they must be consulted + * before the static Composer source locators to mirror that order. + * + * @return array + */ +function autoloadFunctionsPrependedToComposer(): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found +{ + return $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? []; +} + +/** + * Splits the autoload functions registered while loading Composer's autoloader + * and the bootstrap files into those registered before and after Composer's own + * class loader in the spl_autoload queue. This lets PHPStan consult them in the + * same order relative to Composer as PHP does at runtime, instead of always + * invoking them before (or after) the static Composer source locators. + * + * @param list|false $autoloadFunctionsBefore + * @param list|false $autoloadFunctionsAfter + * @return array{prepended: list, appended: list} + */ +function collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found +{ + $prepended = []; + $appended = []; + + if ($autoloadFunctionsBefore === false || $autoloadFunctionsAfter === false) { + return ['prepended' => $prepended, 'appended' => $appended]; + } + + $composerIndex = null; + 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; + } + } + + foreach ($autoloadFunctionsAfter as $index => $after) { + if (is_array($after) && count($after) > 0) { + if ( + is_object($after[0]) + && get_class($after[0]) === ClassLoader::class + ) { + continue; + } + if ($after[0] === 'PHPStan\\PharAutoloader') { + continue; + } + } + + foreach ($autoloadFunctionsBefore as $before) { + if ($after === $before) { + continue 2; + } + } + + if ($composerIndex !== null && $index < $composerIndex) { + $prepended[] = $after; + } else { + $appended[] = $after; + } + } + + return ['prepended' => $prepended, 'appended' => $appended]; +} diff --git a/tests/PHPStan/CollectNewAutoloadFunctionsTest.php b/tests/PHPStan/CollectNewAutoloadFunctionsTest.php new file mode 100644 index 00000000000..9e2c18f551c --- /dev/null +++ b/tests/PHPStan/CollectNewAutoloadFunctionsTest.php @@ -0,0 +1,92 @@ +assertSame( + ['prepended' => [], 'appended' => []], + collectNewAutoloadFunctions(false, false), + ); + + $after = [static function (string $class): void { + }]; + $this->assertSame( + ['prepended' => [], 'appended' => []], + collectNewAutoloadFunctions(false, $after), + ); + } + + public function testAutoloadersAreSplitByComposerPosition(): void + { + $prepended = static function (string $class): void { + }; + $composer = new ClassLoader(); + $appended = static function (string $class): void { + }; + + $before = []; + $after = [$prepended, [$composer, 'loadClass'], $appended]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([$prepended], $result['prepended']); + $this->assertSame([$appended], $result['appended']); + } + + public function testWithoutComposerEverythingIsAppended(): void + { + $first = static function (string $class): void { + }; + $second = static function (string $class): void { + }; + + $result = collectNewAutoloadFunctions([], [$first, $second]); + + $this->assertSame([], $result['prepended']); + $this->assertSame([$first, $second], $result['appended']); + } + + public function testComposerAndPharAutoloaderAndPreexistingAreExcluded(): void + { + $preexisting = static function (string $class): void { + }; + $composer = new ClassLoader(); + $bootstrap = static function (string $class): void { + }; + + $before = [$preexisting]; + $after = [$preexisting, [$composer, 'loadClass'], ['PHPStan\\PharAutoloader', 'loadClass'], $bootstrap]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([], $result['prepended']); + $this->assertSame([$bootstrap], $result['appended']); + } + + public function testPreexistingAutoloaderBeforeComposerIsNotReported(): void + { + $preexisting = static function (string $class): void { + }; + $composer = new ClassLoader(); + $prependedBootstrap = static function (string $class): void { + }; + + // $preexisting was registered before PHPStan loaded the project - it must + // be ignored even though it sits before Composer in the queue. + $before = [$preexisting]; + $after = [$preexisting, $prependedBootstrap, [$composer, 'loadClass']]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([$prependedBootstrap], $result['prepended']); + $this->assertSame([], $result['appended']); + } + +} From d186cbd2621b7e4adc286a3ceb28e45dfa6b1b7b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 20 Jul 2026 15:33:10 +0000 Subject: [PATCH 4/7] Add e2e project for a prepended bootstrap autoloader bug-12972c registers a bootstrap autoloader before Composer's class loader that resolves a class Composer also maps, but with a different API. At runtime the prepended autoloader wins; PHPStan now reflects the same version instead of Composer's, so analysis matches runtime. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e-tests.yml | 4 ++++ e2e/bug-12972c/.gitignore | 2 ++ e2e/bug-12972c/autoloader.php | 12 ++++++++++++ e2e/bug-12972c/classmap/Thing.php | 10 ++++++++++ e2e/bug-12972c/composer.json | 7 +++++++ e2e/bug-12972c/consumer/consumer.php | 10 ++++++++++ e2e/bug-12972c/phpstan.dist.neon | 8 ++++++++ e2e/bug-12972c/prepended/Thing.php | 10 ++++++++++ e2e/bug-12972c/real-world.php | 6 ++++++ 9 files changed, 69 insertions(+) create mode 100644 e2e/bug-12972c/.gitignore create mode 100644 e2e/bug-12972c/autoloader.php create mode 100644 e2e/bug-12972c/classmap/Thing.php create mode 100644 e2e/bug-12972c/composer.json create mode 100644 e2e/bug-12972c/consumer/consumer.php create mode 100644 e2e/bug-12972c/phpstan.dist.neon create mode 100644 e2e/bug-12972c/prepended/Thing.php create mode 100644 e2e/bug-12972c/real-world.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4c275ca6b32..b9df7364e26 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -136,6 +136,10 @@ jobs: cd e2e/bug-12972b composer install ../../bin/phpstan analyze + - script: | + cd e2e/bug-12972c + composer install + ../../bin/phpstan analyze - script: | cd e2e/bug-14724 composer install diff --git a/e2e/bug-12972c/.gitignore b/e2e/bug-12972c/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/bug-12972c/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/bug-12972c/autoloader.php b/e2e/bug-12972c/autoloader.php new file mode 100644 index 00000000000..0eabb486506 --- /dev/null +++ b/e2e/bug-12972c/autoloader.php @@ -0,0 +1,12 @@ +prependedOnly(); +} diff --git a/e2e/bug-12972c/phpstan.dist.neon b/e2e/bug-12972c/phpstan.dist.neon new file mode 100644 index 00000000000..c9f081939c2 --- /dev/null +++ b/e2e/bug-12972c/phpstan.dist.neon @@ -0,0 +1,8 @@ +parameters: + level: 9 + + paths: + - consumer + + bootstrapFiles: + - autoloader.php diff --git a/e2e/bug-12972c/prepended/Thing.php b/e2e/bug-12972c/prepended/Thing.php new file mode 100644 index 00000000000..b97d6a4750d --- /dev/null +++ b/e2e/bug-12972c/prepended/Thing.php @@ -0,0 +1,10 @@ +prependedOnly(), "\n"; From 3cdb0fa483b9baac2f6d8075ea961121161bfe42 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 20 Jul 2026 15:49:40 +0000 Subject: [PATCH 5/7] Require explicit $prependedToComposer argument on AutoloadFunctionsSourceLocator Callers now pass the boolean explicitly instead of relying on a default, making the prepended/appended intent visible at each construction site. Co-Authored-By: Claude Opus 4.8 --- .../BetterReflection/BetterReflectionSourceLocatorFactory.php | 1 + .../SourceLocator/AutoloadFunctionsSourceLocator.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php index 2a97a8e4efd..055df460810 100644 --- a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php +++ b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php @@ -203,6 +203,7 @@ public function create(): SourceLocator $astLocator, $this->reflectionSourceStubber, ), + false, ); $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php index ad7a867cfbc..8ef7d77eabb 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -26,7 +26,7 @@ final class AutoloadFunctionsSourceLocator implements SourceLocator public function __construct( private AutoloadSourceLocator $autoloadSourceLocator, private ReflectionClassSourceLocator $reflectionClassSourceLocator, - private bool $prependedToComposer = false, + private bool $prependedToComposer, ) { } From 3c7833bd7350a45495e2c46f8bdc77b5a0439abf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 24 Jul 2026 08:45:30 +0000 Subject: [PATCH 6/7] Un-prefix Composer\Autoload\ClassLoader in scoped src/autoloadFunctions.php The Composer ClassLoader identity check used to exclude Composer's own class loader from the bootstrap autoloaders PHPStan invokes for real was moved from bin/phpstan into src/autoloadFunctions.php. bin/phpstan is on the scoper whitelist that reverts Composer\Autoload\ClassLoader back to its un-prefixed name, but src/autoloadFunctions.php was not, so in the PHAR the check compared against _PHPStan_xxx\Composer\Autoload\ClassLoader. The analysed project's real Composer loader is un-prefixed and therefore no longer matched, so it was collected as a bootstrap autoload function and invoked for real - require-ing files (e.g. Roave/BetterReflection's invalid inheritance fixtures) and hard-crashing analysis with a fatal that cannot happen at runtime. Co-Authored-By: Claude Opus 4.8 --- compiler/build/scoper.inc.php | 1 + 1 file changed, 1 insertion(+) diff --git a/compiler/build/scoper.inc.php b/compiler/build/scoper.inc.php index f6e29aa24e7..adb2da3eead 100644 --- a/compiler/build/scoper.inc.php +++ b/compiler/build/scoper.inc.php @@ -141,6 +141,7 @@ function (string $filePath, string $prefix, string $content): string { function (string $filePath, string $prefix, string $content): string { if (!in_array($filePath, [ 'bin/phpstan', + 'src/autoloadFunctions.php', 'src/Testing/TestCaseSourceLocatorFactory.php', 'src/Testing/PHPStanTestCase.php', 'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php', From f04e5dd4fb2ca69bba4d1eb63bef74e75ba87b39 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 6 Aug 2026 10:23:13 +0000 Subject: [PATCH 7/7] Document how the bug-12972b e2e autoloader reproduces the issue Co-Authored-By: Claude Opus 5 --- e2e/bug-12972b/autoloader.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/e2e/bug-12972b/autoloader.php b/e2e/bug-12972b/autoloader.php index aaaae3ef08f..9e14926c3b1 100644 --- a/e2e/bug-12972b/autoloader.php +++ b/e2e/bug-12972b/autoloader.php @@ -2,6 +2,17 @@ require_once __DIR__ . '/vendor/autoload.php'; +// Registered *after* Composer's class loader: the third argument (prepend) +// defaults to false, so this ends up at the back of the spl_autoload queue. +// +// \other12972\MyClass is part of Composer's class map (see composer.json), so at +// runtime Composer's class loader resolves it first and this autoloader is never +// invoked for it - running `php real-world.php` therefore never throws. +// +// PHPStan must mirror that order: the Composer class map source locator has to +// resolve the class before this bootstrap autoloader is consulted. Analysing the +// project used to invoke this autoloader first, hitting the LogicException and +// crashing with an internal error that cannot happen at runtime. spl_autoload_register(function($class) { if ($class === \other12972\MyClass::class) { throw new LogicException('this should not happen');