Skip to content

[Bug]: arch() tests still break TestCase::createApplication for subsequent tests when the codebase references Rector (reproducible follow-up to #1423) #1732

Description

@alexkart

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:

  1. 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
    
  2. Illuminate\Foundation\Testing\TestCase::createApplication() resolves the app via require Application::inferBasePath().'/bootstrap/app.php' (src/Illuminate/Foundation/Testing/TestCase.php:47).
  3. 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):

  1. a class in the scanned code referencing Rector classes;
  2. any arch() test — a preset or a single targeted expectation, both reproduce;
  3. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions