Skip to main content
PHP coverage, instrumented

Line and branch coverage for PHP. No Xdebug, no PCOV.

Sonda inserts probes into your source before it loads, the way Istanbul does for JavaScript. The suite runs at its normal speed, and every decision has to show both outcomes.

Get startedNumbers vs Xdebug
$ vendor/bin/sonda test --changed=origin/master PASS Tests\Feature\OrderTest Tests: 4514 passed (12031 assertions) Duration: 174.20s Coverage: lines 12118/12118 (100.00%), branches 4857/4857 (100.00%) Of what changed: lines 86/86 (100.00%), branches 31/31 (100.00%) The changed files are 100% covered. $ vendor/bin/sonda test --files=app/Models/Order.php Uncovered: app/Models/Order.php: lines 48, 71-73; branches 48, 70-74

The suite runs at its own speed

A Laravel 13 application with Filament, Pest 4 and Postgres: 4,514 tests, measured on the same machine. Xdebug with path coverage did not finish in an hour.

No coverage
178s
Sonda, lines and branches
174s
Xdebug, lines only
671s
Xdebug --path-coverage
> 63 min

Memory: 308 MB without coverage, 317 MB with Sonda, 640 MB with Xdebug lines only. With ParaTest and 10 workers: 42s without coverage, 40s with Sonda.

What you get

Built for a merge gate: 100% of the lines and branches of what the merge request changed, in the time the suite already takes.

{ }

Branches, not just lines

Every outcome of if, loops, match, ?:, ??, ?-> and short-circuit operators is a branch with its own probe.

0

No extension

Nothing to install on the runner. Any PHP 8.4+ with your vendor/ is enough; the report comes from php-code-coverage.

≠

Strict by default

An if without else still needs both outcomes tested. Sonda synthesizes the else and counts it.

Δ

Only what changed

--changed measures the files your branch touched and fails if a line or branch of them stays uncovered.

⇢

Only the affected tests

--affected runs just the tests that executed the changed files, from a map the previous run saved.

@

Blade views too

Compiled templates are instrumented and mapped back to the view, line and branch, including custom directives.

How it works

No opcode interception and no path enumeration, which is what makes Xdebug slow. A probe is one array assignment, and the instrumented copy keeps every line number.

Instrument

Each file of your phpunit.xml source gets a probe at every branch outcome. Copies are cached by file hash, so a second run instruments nothing.

Run

A bootstrap registers an autoloader ahead of Composer's that loads the copies. PHPUnit, Pest, ParaTest and artisan test run untouched; each worker saves what it hit.

Report

Hits become a php-code-coverage report: HTML, Clover, Cobertura for the GitLab diff and Code Quality annotations on the uncovered lines.

Your code

public function total(): int
{
if ($this->items === []) {
return 0;
}

return $this->sum() + $this->shipping;
}

What runs (simplified)

public function total(): int
{
$h = &Hits::$current['a1b2c3'];
$h[1] = 1;
if ($this->items === []) {
$h[2] = 1;
return 0;
} else { $h[3] = 1; }

$h[4] = 1;
return $this->sum() + $this->shipping;
}

Two commands to your first report

Composer, then sonda test from the project root. PHP 8.4+, PHPUnit 12 or Pest 4.

Read the getting started guide