Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ jobs:
cd e2e/bug-14514
composer install
../../bin/phpstan analyze bug-14515.php
- script: |
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
Expand Down
34 changes: 9 additions & 25 deletions bin/phpstan
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions e2e/bug-12972b/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor/
composer.lock
9 changes: 9 additions & 0 deletions e2e/bug-12972b/autoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

require_once __DIR__ . '/vendor/autoload.php';

spl_autoload_register(function($class) {
if ($class === \other12972\MyClass::class) {
throw new LogicException('this should not happen');
}
});
Comment on lines +3 to +9

@staabm staabm Jul 20, 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.

the current fix depends on the order in which autoloaders are registered within bootstrap scripts (and whether they are prepend'ed or not).

in case we change the autoloader.php to

<?php

require_once __DIR__ . '/vendor/autoload.php';

spl_autoload_register(function($class) {
	if ($class === \other12972\MyClass::class) {
		throw new LogicException('this should not happen');
	}
}, true, true);

we would get back a similar runtime vs. static analysis inconsistency.

can we detect in which order autoloaders are registered in the bootstrap scripts?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

All verification passes. Here's a summary of what I did to address staabm's review.

The review concern

staabm pointed out that the PR's fix (moving all bootstrap autoloaders to the back of the source-locator chain) depends on registration order: an autoloader registered before Composer's class loader — e.g. spl_autoload_register($fn, true, true) — runs before Composer at runtime, so consulting it after the static Composer locators reintroduces the same runtime-vs-analysis inconsistency in the opposite direction. They asked: can we detect in which order autoloaders are registered?

What I implemented

Yes — I now detect each bootstrap autoloader's position relative to Composer's ClassLoader in the spl_autoload queue and split them into two groups:

  • src/autoloadFunctions.php — new collectNewAutoloadFunctions() helper that finds Composer's ClassLoader in the queue and classifies each newly-registered autoloader as prepended (before Composer) or appended (after Composer), while still excluding Composer itself, the phar loader, and pre-existing autoloaders. Also a new autoloadFunctionsPrependedToComposer() accessor. The logic is robust to PHPStan's own dev ClassLoader also being in the queue (prepends always land at index 0; appends always at the end).
  • bin/phpstan + src/Command/CommandHelper.php — both collection sites now use the shared helper and populate two globals instead of one. This also de-duplicates the two near-identical loops into one place.
  • AutoloadFunctionsSourceLocator — gained a $prependedToComposer flag selecting which group to run.
  • BetterReflectionSourceLocatorFactory — the prepended group is consulted at the front of the chain (before analysed files / Composer class-map / PSR-4), the appended group only as a fallback at the back — mirroring runtime order in both directions.

Tests

  • tests/PHPStan/CollectNewAutoloadFunctionsTest.php — unit tests for the ordering logic (before/after Composer, exclusions, pre-existing filtering).
  • e2e/bug-12972c — a discriminating e2e: a bootstrap autoloader prepended before Composer resolves a class Composer also maps but with a different API. At runtime the prepended version wins; I verified PHPStan now reflects that same version (calling the Composer-only method correctly errors, proving the prepend order is honored). Wired into e2e-tests.yml.

Verification

  • make phpstan — No errors
  • Full make tests (paratest) — 17607 tests, exit 0
  • phpcs on all changed .php files — clean
  • Existing e2e bug-12972b (appended throwing autoloader) — still no crash

Both commits are pushed to create-pull-request/patch-89ch8pu.

7 changes: 7 additions & 0 deletions e2e/bug-12972b/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"autoload": {
"classmap": [
"src/"
]
}
}
8 changes: 8 additions & 0 deletions e2e/bug-12972b/phpstan.dist.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
parameters:
level: 9

paths:
- src

bootstrapFiles:
- autoloader.php
6 changes: 6 additions & 0 deletions e2e/bug-12972b/real-world.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

require 'autoloader.php';

$root = new \Foo12972\MyRoot();
$root->doBar(new \other12972\MyClass());
9 changes: 9 additions & 0 deletions e2e/bug-12972b/src/folder/file2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Foo12972;

use other12972\MyClass;

class MyRoot {
function doBar(MyClass $myClass):void {}
}
10 changes: 10 additions & 0 deletions e2e/bug-12972b/src/other/file.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace other12972;

class MyClass {
public function doSomething(): int
{
return 1;
}
}
2 changes: 2 additions & 0 deletions e2e/bug-12972c/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor/
composer.lock
12 changes: 12 additions & 0 deletions e2e/bug-12972c/autoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

require_once __DIR__ . '/vendor/autoload.php';

// Registered *before* Composer's class loader (third argument = prepend).
// At runtime this resolves \shared12972c\Thing before Composer ever sees it,
// so PHPStan must consult it before the Composer class map as well.
spl_autoload_register(function ($class) {
if ($class === \shared12972c\Thing::class) {
require __DIR__ . '/prepended/Thing.php';
}
}, true, true);
10 changes: 10 additions & 0 deletions e2e/bug-12972c/classmap/Thing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace shared12972c;

class Thing {
public function composerOnly(): int
{
return 1;
}
}
7 changes: 7 additions & 0 deletions e2e/bug-12972c/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"autoload": {
"classmap": [
"classmap/"
]
}
}
10 changes: 10 additions & 0 deletions e2e/bug-12972c/consumer/consumer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace consumer12972c;

use shared12972c\Thing;

function run(Thing $thing): int
{
return $thing->prependedOnly();
}
8 changes: 8 additions & 0 deletions e2e/bug-12972c/phpstan.dist.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
parameters:
level: 9

paths:
- consumer

bootstrapFiles:
- autoloader.php
10 changes: 10 additions & 0 deletions e2e/bug-12972c/prepended/Thing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace shared12972c;

class Thing {
public function prependedOnly(): int
{
return 2;
}
}
6 changes: 6 additions & 0 deletions e2e/bug-12972c/real-world.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

require 'autoloader.php';

$thing = new \shared12972c\Thing();
echo $thing->prependedOnly(), "\n";
23 changes: 11 additions & 12 deletions src/Command/CommandHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,18 @@ public function create(): SourceLocator
}

$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 = [];
Expand Down Expand Up @@ -184,6 +190,22 @@ public function create(): SourceLocator
$this->phpVersion,
));

// 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(
$astLocator,
$this->reflectionSourceStubber,
),
false,
);

$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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@
use function class_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,
)
{
}
Expand All @@ -35,7 +42,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);
Expand Down
Loading
Loading