phpfui/phpunit-syntax-coverage
A PHPUnit extension that measures PHP syntax coverage, showing which language constructs and parser nodes your test suite actually exercises. Use it to spot untested syntax like new operators, match expressions, or traits, then close those gaps with focused tests.
What it gives you. PHPUnit's code coverage tells you which lines inside loaded files ran. It says nothing about files that no test ever touched — those show up as 0% (or vanish entirely if they're outside <source>). phpfui/phpunit-syntax-coverage closes that gap: it hooks into the PHPUnit run and asserts that every PHP file in your source tree was at least loaded by the suite. Think of it as "no orphan files" rather than "no uncovered lines."
Install and pin against your PHPUnit major. This is the first thing to check, because extension wiring changed in PHPUnit 10:
composer require --dev phpfui/phpunit-syntax-coverage
composer show phpfui/phpunit-syntax-coverage # inspect the phpunit/phpunit constraint
If you're on PHPUnit 9, the integration is a listener (<listeners><listener class="..."/></listeners>). On PHPUnit 10/11 it's an extension (<extensions><bootstrap class="..."/></extensions>). The package is small and single-maintainer — confirm the exact class name and parameter keys by opening src/ before copying any snippet, including the one below, which shows the shape rather than a guaranteed signature:
<!-- phpunit.xml -->
<extensions>
<bootstrap class="PHPFUI\PHPUnitSyntaxCoverage\Extension">
<parameter name="src" value="src"/>
</bootstrap>
</extensions>
First use case: a legacy repo audit. Point it at src/, run the full suite once, and read the failure output as a work list. You'll typically get three buckets: genuinely dead files, files loaded only via require in a bootstrap (so they pass but aren't really tested), and generated/vendored code you forgot to exclude. That last bucket is your exclusion list, and it's the fastest win.
Where to look first. README → composer.json (version constraints tell you which PHPUnit API it targets) → src/. At this size the source is the documentation; reading the extension class takes five minutes and answers every question the README doesn't.
Baseline-then-gate. A 200-file legacy codebase will produce 60+ orphans on day one, so don't make the first PR red. Either the package supports an ignore/exclude list, or you wrap it:
# once, to capture the current state
vendor/bin/phpunit 2>&1 | grep -i 'not covered\|missing' > syntax-baseline.txt
Then commit the baseline and diff against it in CI, failing only on new entries. If the package has no baseline mechanism, implement it in your CI script — don't fork unless you have to.
Run it as part of the normal suite, never standalone. The check is only meaningful if the whole suite ran in the same process. So:
"scripts": {
"test": "@php vendor/bin/phpunit",
"test:full": ["@test", "@phpstan", "@phpcs"]
}
Never gate on phpunit --filter=SomeTest — a partial run will report every file outside that filter as orphaned.
Split the scan by tree in monorepos. A separate phpunit-src.xml scoped to src/ (with tests/ excluded from the scan) keeps runtime sane and keeps the failure message readable:
<parameter name="src" value="src"/>
<parameter name="exclude" value="src/Generated,src/database/migrations"/>
Pair it with a class_exists() smoke test. The cheapest fix for a reported orphan that is real code is a single generated test that autoloads every class. That also catches PSR-4 mismatches, which is a nice side effect.
foreach (glob(__DIR__ . '/../src/**/*.php') as $file) {
$class = /* FQCN from path */;
$this->assertTrue(class_exists($class), $class);
}
Use it as a deletion tool, not just a gate. The highest-value workflow isn't the CI check — it's running it once a quarter against a mature codebase and deleting the files it flags. Nothing else in the PHP toolchain finds genuinely unreachable source.
Wire it into the shared base TestCase if the package ships a trait or base class rather than a pure extension. Put the trait on tests/TestCase.php once and every test class inherits the behaviour — that's less config to keep in sync than XML.
vendor/, generated code, migrations, stubs, fixtures, tests/ itself. The default may not exclude anything.@codeCoverageIgnore and <source> filters. Syntax coverage generally bypasses these — which is exactly the point, but it surprises people who assume a file marked ignored is invisible to every tool.--do-not-cache-result when you're debugging why CI fails and local passes.src parameter actually resolves. Run with --debug / --display-all-issues depending on version to see extension output.composer.json, and if you need a baseline file or richer reporting, fork it — the surface area is small enough that maintaining a fork is cheaper than waiting for upstream.PHPUnit\Runner\Extension\Extension interface (bootstrap(Configuration, Facade, ParameterCollection)) plus subscribers (Facade::registerSubscriber()). If you need custom behaviour, write your own tiny extension that reuses the package's file iterator rather than patching the package.paratest assumptions. Whether it aggregates across worker processes depends on implementation; verify before relying on it in a parallelised pipeline.How can I help you explore Laravel packages today?