PHP Code Coverage: How to Measure It, What the Numbers Mean, and What to Do With Them

#php code coverage
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most PHP teams treat code coverage one of two ways. They ignore it, or they chase a percentage without asking what it measures. Neither gets you closer to code you can trust.

Code coverage tells you which lines your test suite executed. It does not tell you whether your tests would catch a bug. That distinction matters more than any single number on a dashboard, and it is the reason a project can sit at 85 percent coverage and still ship broken code to production.

What coverage actually measures

PHPUnit, the standard testing framework for PHP, reports coverage in three flavors, and each answers a different question.

Line coverage asks whether a given line executed at least once during the test run. It is the easiest metric to compute and the one most dashboards show by default. It is also the weakest signal: a line can execute without any assertion checking that it did the right thing.

Branch coverage asks whether every path through a conditional executed. An if statement has two branches. Line coverage is satisfied if either branch runs once. Branch coverage requires both, which catches the case where your tests only ever exercise the happy path and never the else.

Path coverage goes further still, checking every combination of branches through a function. It is rarely tracked in PHP tooling because the number of paths grows exponentially with each added condition, but it is the theoretical ceiling that line and branch coverage are approximating.

For most PHP projects, branch coverage is the more honest target. Line coverage alone will tell you a validation function is "covered" even if you never tested what happens when validation fails, which is usually the part worth testing.

Setting up coverage measurement

PHPUnit does not compute coverage itself. It needs a coverage driver, and PHP gives you two real options: Xdebug and PCOV.

Xdebug is the more familiar choice because most PHP developers already have it installed for step debugging. It computes line, branch, and path coverage, and it integrates with the rest of Xdebug's profiling tools. The cost is speed. Xdebug's coverage mode adds substantial overhead because it instruments the interpreter, and on a large test suite this can turn a two-minute run into a fifteen-minute one.

PCOV was built specifically to solve that problem. It only computes line coverage, has no debugging features, and runs an order of magnitude faster than Xdebug in coverage mode because it hooks into the Zend engine's opcode execution rather than layering a full debugger on top. If your CI pipeline runs coverage on every push and every run is timing out or eating your build minutes, PCOV is almost always the right trade.

A practical split: use PCOV in CI for the coverage number that gates your pipeline, and reach for Xdebug locally when you want branch-level detail on a specific class you're actively testing.

# .env or CI config
php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html coverage/
<!-- phpunit.xml -->
<coverage>
    <report>
        <html outputDirectory="coverage/html"/>
        <clover outputFile="coverage/clover.xml"/>
    </report>
</coverage>

Keep the coverage step separate from your fast feedback loop. Developers should be able to run the suite without coverage instrumentation for quick iteration, and reserve the instrumented run for CI or a deliberate local check.

Reading a coverage report without fooling yourself

A coverage percentage by itself tells you almost nothing. Seventy percent coverage could mean the untested thirty percent is dead code nobody calls, or it could mean the untested thirty percent is your payment reconciliation logic. The number is identical either way.

What actually matters is where the gaps are. Open the HTML report PHPUnit generates and look at which files and which methods sit at the bottom. A coverage report is a map, not a score, and the useful work happens when you read the map.

Some patterns worth checking specifically:

Error handling paths are the most commonly skipped code in PHP applications, because writing a test that deliberately breaks something takes more effort than writing one for the success case. If your catch blocks show as uncovered, that is worth closer attention than a missing getter.

Business-critical calculations, anything touching money, permissions, or data that feeds a compliance report, deserve coverage regardless of what the aggregate percentage says. A 60 percent overall number with full coverage on billing logic is in better shape than 90 percent with billing logic untested.

Framework glue code, controllers that do nothing but call a service and return a response, is usually fine to leave partially covered. The risk there is low, and chasing coverage on boilerplate is where teams waste time without reducing real risk.

A useful habit: when a coverage number changes after a pull request, look at what specifically moved before deciding whether the change is good or bad. A drop from 82 to 79 percent because someone added a well-tested feature alongside some untested config plumbing is not the same as a drop because someone skipped tests on new business logic.

Where coverage lies to you

A test can execute a line and assert nothing meaningful about it. This is the blind spot every coverage tool has by design: coverage measures execution, not verification.

public function testCalculateDiscount(): void
{
    $result = $this->pricingService->calculateDiscount($order);
    $this->assertNotNull($result);
}

This test gives you full line coverage on calculateDiscount(). It also would not fail if the method returned the wrong discount amount, applied the wrong percentage, or ignored the order's currency entirely. The line ran. The number went up. The bug shipped anyway.

This is where mutation testing earns its place in a serious PHP test suite. Tools like infection/infection work by deliberately introducing small bugs, called mutants, into your code: flipping a comparison operator, changing a return value, removing a method call, and then running your test suite against each mutated version. If your tests still pass with the bug in place, that mutant "escaped," and it tells you exactly where your assertions are too weak to catch a real defect.

composer require --dev infection/infection
vendor/bin/infection --min-msi=70 --min-covered-msi=80

Infection's mutation score indicator gives you a percentage of mutants your suite caught. Unlike raw coverage, this number is much harder to game, because passing it requires assertions that actually check behavior, not just code that executes. Running infection is slower than a plain coverage run since it executes your suite once per mutant, so most teams run it on a schedule or on critical modules rather than on every commit.

What to do with the numbers

A coverage target only works as a floor, not a goal. Requiring 70 percent minimum coverage on new pull requests catches the case where a feature ships with zero tests. It does nothing to guarantee the tests that exist are any good, which is exactly why pairing a coverage gate with periodic mutation testing on your critical paths gives you a fuller picture than either metric alone.

For client projects, Wolf-Tech sets coverage expectations per module rather than as one blanket number across an entire codebase. Payment processing, authentication, and anything that writes to a database under a unique constraint get held to a high bar, including mutation testing on the riskiest logic. Presentation layers and thin controllers get a lower bar, because the return on writing exhaustive tests there is small.

If you're inheriting a codebase with no coverage history, resist the urge to backfill tests everywhere at once. Start by measuring what exists today as a baseline, then require that new and modified code meets your target through a diff coverage check (most CI coverage tools support this) rather than trying to raise the whole codebase's number in one pass. This gets you improving trend lines without the multi week detour of retrofitting tests onto stable code nobody is touching.

Coverage tooling is cheap to set up and easy to misread. The value is in treating the report as a starting point for a conversation about risk, not as a grade to optimize. A team that reads their coverage report and asks what's still untested in the parts that matter gets more out of PHPUnit than a team chasing a percentage on a dashboard.

If your PHP codebase has coverage numbers that look fine on paper but you're not confident the tests would catch a real regression, that gap between the metric and the reality is exactly what a code quality audit is built to find. Wolf-Tech reviews test suites as part of every codebase assessment, not just the code they're testing. Reach out at hello@wolf-tech.io or find more about how we work at wolf-tech.io.