Dealing with Isolated PHPStan 1 and the PHPUnit 13 Blindspot

We love isolated dev tools. Shoving PHPStan, Rector, or PHP CS Fixer into separate subdirectories like .tools/phpstan/ with their own composer.json is a great way to avoid dependency hell in your root project.

Until it completely blinds your analysis pipeline.

If you recently jumped to PHPUnit 13 and your static analysis suddenly went off the rails with phantom errors like unknown class PHPUnit\Framework\TestCase, you've hit a classic isolation wall. Let's look at why it breaks and how to fix it properly.


The Symptom

Your test suite runs inside Docker. It passes flawlessly. Every assertion goes green. Yet, the moment you run PHPStan, your terminal explodes:

 ------ ---------------------------------------------------------------------------- 
  Line   tests/Client/FakeClientTest.php                                             
 ------ ---------------------------------------------------------------------------- 
  12     Class App\Tests\Client\FakeClientTest extends unknown class                 
         PHPUnit\Framework\TestCase.                                                 
         💡 Learn more at https://phpstan.org/user-guide/discovering-symbols         
  30     Call to an undefined static method                                          
         App\Tests\Client\FakeClientTest::assertInstanceOf().                        
 ------ ---------------------------------------------------------------------------- 

You look at your phpstan.neon.dist. You already bridged the gap by telling PHPStan where to find the project's autoloader:

parameters:
    level: max
    paths:
        - src/
        - tests/
    bootstrapFiles:
        - vendor/autoload.php

You even double-check the autoloader manually via PHP:

php -r "require 'vendor/autoload.php'; echo class_exists('PHPUnit\Framework\TestCase') ? '🟢 OUI' : '🔴 NON';"

The console returns 🟢 OUI. The class is right there. So why is PHPStan blind?


Why Did This Work Fine on PHPUnit 9?

If you have this exact same layout running on an older project with PHPUnit 9.5, it works without a hitch. What changed?

1. The Architectural Shift in PHPUnit 10+

In PHPUnit 9, TestCase was a pretty monolithic class. PHPStan's static reflection engine (BetterReflection) had no trouble mapping it from an external directory.

With PHPUnit 10 (and up through v13), the framework was completely refactored. TestCase now relies on a deep web of internal interfaces and traits. When PHPStan tries to inspect it remotely across directory boundaries via a bootstrapped autoloader, the reflection engine gets lost in the inheritance tree and safely assumes the class doesn't exist.

2. The Legacy Extension Trap

If you look at your isolated .tools/phpstan/composer.json, you probably pulled a legacy constraint from an older project boilerplate:

"require": {
    "phpstan/phpstan": "*",
    "phpstan/phpstan-phpunit": "^1.1"
}

That ^1.1 constraint locks the PHPUnit extension to its 1.x branch, which was historically built for PHPUnit 9. Because the extension is locked to v1.x, Composer silently pins core phpstan/phpstan to a legacy version too (like 1.12.x), completely ignoring your * wildcard. You are effectively analyzing a modern PHPUnit 13 codebase with an outdated engine.


The Clean Fix: Drop the Legacy Constraints

Instead of fighting paths with scanDirectories or stuffing a dummy copy of PHPUnit into your tools directory, just upgrade your toolchain. PHPStan 2.0 and its phpstan-phpunit 2.0 extension handle the complex architecture of modern PHPUnit natively.

1. Bump to v2

Open .tools/phpstan/composer.json and force the upgrade:

{
    "require": {
        "php": ">=8.4",
        "phpstan/phpstan": "^2.0",
        "phpstan/phpstan-phpunit": "^2.0"
    },
    "config": {
        "bin-dir": "./",
        "sort-packages": true
    }
}

2. Refresh the Environment

Run an update inside your tools directory to rebuild the lock file:

cd .tools/phpstan && composer update

3. Clear Cache & Analyze

Make sure your phpstan.neon.dist uses the absolute path variable to target your root vendor directory securely:

parameters:
    level: max
    paths:
        - src/
        - tests/
    bootstrapFiles:
        - %currentWorkingDirectory%/vendor/autoload.php

includes:
    - .tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon
    - .tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon

Nuke the old analysis cache so you don't run into stale results:

.tools/phpstan/phpstan clear-result-cache

Run your analyzer again. The phantom errors will disappear, and you'll get your clean green light back without degrading your isolated architecture.