<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="https://ktherage.github.io/xsl/atom.xsl" media="all"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <id>https://ktherage.github.io/tags/qa-tools/</id>
  <title>Kévin THÉRAGE | Expert Symfony Developer - QA Tools</title>
  <subtitle><![CDATA[Technical blog over web development, Symfony, PHP and best practices. Find tutorials and advices for developers.]]></subtitle>
  <link href="https://ktherage.github.io/tags/qa-tools/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/tags/qa-tools/" rel="alternate" type="text/html" />
  <updated>2026-05-29T15:09:44+00:00</updated>
  <author>
    <name>Kévin THÉRAGE</name>
    <uri>https://ktherage.github.io/</uri>
  </author>
  <entry xml:lang="en">
    <id>https://ktherage.github.io/blog/dealing_with_isolated_phpstan_1_and_the_phpunit_13_blindspot/</id>
    <title>Dealing with Isolated PHPStan 1 and the PHPUnit 13 Blindspot</title>
    <published>2026-05-29T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/blog/dealing_with_isolated_phpstan_1_and_the_phpunit_13_blindspot/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>We love isolated dev tools. Shoving PHPStan, Rector, or PHP CS Fixer into separate subdirectories like <code translate="no">.tools/phpstan/</code> with their own <code translate="no">composer.json</code> is a great way to avoid dependency hell in your root project.</p>
<p>Until it completely blinds your analysis pipeline.</p>
<p>If you recently jumped to <strong>PHPUnit 13</strong> and your static analysis suddenly went off the rails with phantom errors like <code translate="no">unknown class PHPUnit\Framework\TestCase</code>, you've hit a classic isolation wall. Let's look at why it breaks and how to fix it properly.</p>
<hr>
<h2 id="the-symptom">The Symptom</h2>
<p>Your test suite runs inside Docker. It passes flawlessly. Every assertion goes green.
Yet, the moment you run PHPStan, your terminal explodes:</p>
<pre><code class="language-text" translate="no"> ------ ---------------------------------------------------------------------------- 
  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().                        
 ------ ---------------------------------------------------------------------------- 
</code></pre>
<p>You look at your <code translate="no">phpstan.neon.dist</code>. You already bridged the gap by telling PHPStan where to find the project's autoloader:</p>
<pre><code class="language-yaml hljs yaml" translate="no"><span class="hljs-attr">parameters:</span>
    <span class="hljs-attr">level:</span> <span class="hljs-string">max</span>
    <span class="hljs-attr">paths:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">src/</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">tests/</span>
    <span class="hljs-attr">bootstrapFiles:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">vendor/autoload.php</span>
</code></pre>
<p>You even double-check the autoloader manually via PHP:</p>
<pre><code class="language-bash hljs bash" translate="no">php -r <span class="hljs-string">"require 'vendor/autoload.php'; echo class_exists('PHPUnit\Framework\TestCase') ? '🟢 OUI' : '🔴 NON';"</span></code></pre>
<p>The console returns <code translate="no">🟢 OUI</code>. The class is right there. So why is PHPStan blind?</p>
<hr>
<h2 id="why-did-this-work-fine-on-phpunit-9">Why Did This Work Fine on PHPUnit 9?</h2>
<p>If you have this exact same layout running on an older project with PHPUnit 9.5, it works without a hitch. What changed?</p>
<h3 id="1-the-architectural-shift-in-phpunit-10">1. The Architectural Shift in PHPUnit 10+</h3>
<p>In PHPUnit 9, <code translate="no">TestCase</code> was a pretty monolithic class. PHPStan's static reflection engine (<code translate="no">BetterReflection</code>) had no trouble mapping it from an external directory.</p>
<p>With PHPUnit 10 (and up through v13), the framework was completely refactored. <code translate="no">TestCase</code> 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.</p>
<h3 id="2-the-legacy-extension-trap">2. The Legacy Extension Trap</h3>
<p>If you look at your isolated <code translate="no">.tools/phpstan/composer.json</code>, you probably pulled a legacy constraint from an older project boilerplate:</p>
<pre><code class="language-json hljs json" translate="no"><span class="hljs-string">"require"</span>: {
    <span class="hljs-attr">"phpstan/phpstan"</span>: <span class="hljs-string">"*"</span>,
    <span class="hljs-attr">"phpstan/phpstan-phpunit"</span>: <span class="hljs-string">"^1.1"</span>
}
</code></pre>
<p>That <code translate="no">^1.1</code> constraint locks the PHPUnit extension to its <strong>1.x branch</strong>, which was historically built for PHPUnit 9. Because the extension is locked to v1.x, Composer silently pins core <code translate="no">phpstan/phpstan</code> to a legacy version too (like <code translate="no">1.12.x</code>), completely ignoring your <code translate="no">*</code> wildcard. You are effectively analyzing a modern PHPUnit 13 codebase with an outdated engine.</p>
<hr>
<h2 id="the-clean-fix-drop-the-legacy-constraints">The Clean Fix: Drop the Legacy Constraints</h2>
<p>Instead of fighting paths with <code translate="no">scanDirectories</code> or stuffing a dummy copy of PHPUnit into your tools directory, just upgrade your toolchain. <strong>PHPStan 2.0</strong> and its <strong>phpstan-phpunit 2.0</strong> extension handle the complex architecture of modern PHPUnit natively.</p>
<h3 id="1-bump-to-v2">1. Bump to v2</h3>
<p>Open <code translate="no">.tools/phpstan/composer.json</code> and force the upgrade:</p>
<pre><code class="language-json hljs json" translate="no">{
    <span class="hljs-attr">"require"</span>: {
        <span class="hljs-attr">"php"</span>: <span class="hljs-string">"&gt;=8.4"</span>,
        <span class="hljs-attr">"phpstan/phpstan"</span>: <span class="hljs-string">"^2.0"</span>,
        <span class="hljs-attr">"phpstan/phpstan-phpunit"</span>: <span class="hljs-string">"^2.0"</span>
    },
    <span class="hljs-attr">"config"</span>: {
        <span class="hljs-attr">"bin-dir"</span>: <span class="hljs-string">"./"</span>,
        <span class="hljs-attr">"sort-packages"</span>: <span class="hljs-literal">true</span>
    }
}
</code></pre>
<h3 id="2-refresh-the-environment">2. Refresh the Environment</h3>
<p>Run an update inside your tools directory to rebuild the lock file:</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-built_in">cd</span> .tools/phpstan &amp;&amp; composer update
</code></pre>
<h3 id="3-clear-cache-analyze">3. Clear Cache &amp; Analyze</h3>
<p>Make sure your <code translate="no">phpstan.neon.dist</code> uses the absolute path variable to target your root vendor directory securely:</p>
<pre><code class="language-yaml hljs yaml" translate="no"><span class="hljs-attr">parameters:</span>
    <span class="hljs-attr">level:</span> <span class="hljs-string">max</span>
    <span class="hljs-attr">paths:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">src/</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">tests/</span>
    <span class="hljs-attr">bootstrapFiles:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">%currentWorkingDirectory%/vendor/autoload.php</span>

<span class="hljs-attr">includes:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">.tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">.tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon</span>
</code></pre>
<p>Nuke the old analysis cache so you don't run into stale results:</p>
<pre><code class="language-bash hljs bash" translate="no">.tools/phpstan/phpstan clear-result-cache
</code></pre>
<p>Run your analyzer again. The phantom errors will disappear, and you'll get your clean green light back without degrading your isolated architecture.</p>]]>
    </content>
  </entry>
</feed>
