What Happened
This is a follow-up to #1423, which was closed as not reproducible. The bug still reproduces on current versions (Pest v4.7.0, PHP 8.3.30, Laravel v13.12.0, rector/rector 2.4.5), and after the closure @InvisibleSmiley confirmed it independently and identified the condition that makes reproduction hit-or-miss — the arch test must run before the app-booting tests in the same process (the default Laravel phpunit.xml suite order, Unit → Feature, does exactly that when the arch test lives in tests/Unit). Filing fresh with the load-bearing conditions spelled out, the root cause traced into vendor code, and a verified workaround.
When any arch() expectation runs in a project that contains a class referencing Rector classes (e.g. a custom Rector rule under app/), every subsequent test in the same process that boots Laravel fails with:
FAILED Tests\Feature\… > … Error
Failed opening required '/path/to/app/vendor/rector/rector/bootstrap/app.php' (include_path='.:/usr/share/php')
Root cause (traced)
Three steps, each verifiable:
- The arch scan autoloads the scanned classes. Loading the custom rule (
… extends Rector\Rector\AbstractRector) pulls in rector/rector, whose scoped autoloader registers itself as an additional Composer ClassLoader. After the arch test, array_keys(ClassLoader::getRegisteredLoaders()) is:
[0] => /path/to/app/vendor/rector/rector/vendor
[1] => /path/to/app/vendor
Illuminate\Foundation\Testing\TestCase::createApplication() resolves the app via require Application::inferBasePath().'/bootstrap/app.php' (src/Illuminate/Foundation/Testing/TestCase.php:47).
Application::inferBasePath() takes dirname() of the first registered loader path (src/Illuminate/Foundation/Application.php):
default => dirname(array_values(array_filter(
array_keys(ClassLoader::getRegisteredLoaders()),
fn ($path) => ! str_starts_with($path, 'phar://'),
))[0]),
With rector's loader registered first, the inferred base path becomes vendor/rector/rector, and the require of its nonexistent bootstrap/app.php fails.
So the arch run permanently perturbs global autoloader state for the rest of the PHPUnit process; any package that registers a scoped ClassLoader when one of its classes is touched by the scan (rector is the common case) will break it.
How to Reproduce
Three ingredients are load-bearing (missing any one of them makes it pass, which is likely why #1423 didn't reproduce):
- a class in the scanned code referencing Rector classes;
- any
arch() test — a preset or a single targeted expectation, both reproduce;
- a Laravel-booting test running after the arch test in the same process (default suite order Unit → Feature suffices).
laravel new app # prompts: no starter kit, Pest, SQLite
cd app
composer require --dev rector/rector
mkdir -p app/Rector/Rules
cat > app/Rector/Rules/SomeCustomRector.php <<'PHP'
<?php
declare(strict_types=1);
namespace App\Rector\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Trait_;
use Rector\Contract\Rector\ConfigurableRectorInterface;
use Rector\Rector\AbstractRector;
final class SomeCustomRector extends AbstractRector implements ConfigurableRectorInterface
{
public function configure(array $configuration): void {}
/** @return array<class-string<Node>> */
public function getNodeTypes(): array
{
return [Trait_::class, Class_::class];
}
public function refactor(Node $node): ?Node
{
return $node;
}
}
PHP
cat > tests/Unit/PresetTest.php <<'PHP'
<?php
declare(strict_types=1);
arch()->preset()->php();
PHP
vendor/bin/pest
Result: Tests\Unit\PresetTest passes, then Tests\Feature\ExampleTest fails with the Failed opening required '…/vendor/rector/rector/bootstrap/app.php' error above. The same happens in our real application with a single targeted expectation (arch()->expect(SomeTrait::class)->toOnlyBeUsedIn(SomeClass::class)) — it is not preset-specific.
Sample Repository
https://github.com/alexkart-examples/pest-rector-bug — the sample repo from #1423 (built on Pest 3.8.2; the script above is the same recipe). The bug reproduces identically on Pest 4.7.0, so it has persisted across the major version.
Verified workaround (confirms the diagnosis)
Pinning the base path so inferBasePath() never consults the loaders fixes it:
APP_BASE_PATH=$(pwd) vendor/bin/pest # all tests pass, arch test included
(Equivalently, $_ENV['APP_BASE_PATH'] = dirname(__DIR__); at the top of tests/Pest.php.)
Suggested direction
Since the arch layer is what perturbs the process-global loader state, it seems like its responsibility to contain that — e.g. snapshot ClassLoader::getRegisteredLoaders() before the scan and unregister newcomers afterwards; alternatively the Laravel plugin could pin APP_BASE_PATH from the already-booted application before arch expectations run. Happy to test a patch — the sample repository above is ready to clone.
Versions
|
|
| Pest |
v4.7.0 |
| PHP |
8.3.30 |
| Laravel |
v13.12.0 |
| rector/rector |
2.4.5 |
| OS |
Linux (Ubuntu) |
What Happened
This is a follow-up to #1423, which was closed as not reproducible. The bug still reproduces on current versions (Pest v4.7.0, PHP 8.3.30, Laravel v13.12.0, rector/rector 2.4.5), and after the closure @InvisibleSmiley confirmed it independently and identified the condition that makes reproduction hit-or-miss — the arch test must run before the app-booting tests in the same process (the default Laravel
phpunit.xmlsuite order, Unit → Feature, does exactly that when the arch test lives intests/Unit). Filing fresh with the load-bearing conditions spelled out, the root cause traced into vendor code, and a verified workaround.When any
arch()expectation runs in a project that contains a class referencing Rector classes (e.g. a custom Rector rule underapp/), every subsequent test in the same process that boots Laravel fails with:Root cause (traced)
Three steps, each verifiable:
… extends Rector\Rector\AbstractRector) pulls inrector/rector, whose scoped autoloader registers itself as an additional ComposerClassLoader. After the arch test,array_keys(ClassLoader::getRegisteredLoaders())is:Illuminate\Foundation\Testing\TestCase::createApplication()resolves the app viarequire Application::inferBasePath().'/bootstrap/app.php'(src/Illuminate/Foundation/Testing/TestCase.php:47).Application::inferBasePath()takesdirname()of the first registered loader path (src/Illuminate/Foundation/Application.php):vendor/rector/rector, and therequireof its nonexistentbootstrap/app.phpfails.So the arch run permanently perturbs global autoloader state for the rest of the PHPUnit process; any package that registers a scoped
ClassLoaderwhen one of its classes is touched by the scan (rector is the common case) will break it.How to Reproduce
Three ingredients are load-bearing (missing any one of them makes it pass, which is likely why #1423 didn't reproduce):
arch()test — a preset or a single targeted expectation, both reproduce;Result:
Tests\Unit\PresetTestpasses, thenTests\Feature\ExampleTestfails with theFailed opening required '…/vendor/rector/rector/bootstrap/app.php'error above. The same happens in our real application with a single targeted expectation (arch()->expect(SomeTrait::class)->toOnlyBeUsedIn(SomeClass::class)) — it is not preset-specific.Sample Repository
https://github.com/alexkart-examples/pest-rector-bug — the sample repo from #1423 (built on Pest 3.8.2; the script above is the same recipe). The bug reproduces identically on Pest 4.7.0, so it has persisted across the major version.
Verified workaround (confirms the diagnosis)
Pinning the base path so
inferBasePath()never consults the loaders fixes it:(Equivalently,
$_ENV['APP_BASE_PATH'] = dirname(__DIR__);at the top oftests/Pest.php.)Suggested direction
Since the arch layer is what perturbs the process-global loader state, it seems like its responsibility to contain that — e.g. snapshot
ClassLoader::getRegisteredLoaders()before the scan and unregister newcomers afterwards; alternatively the Laravel plugin could pinAPP_BASE_PATHfrom the already-booted application before arch expectations run. Happy to test a patch — the sample repository above is ready to clone.Versions