Writing a PHPUnit extension

I published turkerdev/phpunit-json-coverage-report in April. It exists because the built-in coverage formats are for humans or for one specific hosted service, and the question we kept wanting to ask — what is the coverage of the lines this pull request changed — needs a machine-readable per-line report.

The symptom

$ vendor/bin/phpunit --coverage-text | tail -3
  Classes: 61.11% (44/72)
  Methods: 71.42% (410/574)
  Lines:   76.41% (8,802/11,518)

# and the question we wanted to ask:
#   of the 188 lines this pull request changed, how
#   many are covered?
#
# clover XML has the data and is 4 MB of nested
# elements. the text format has a percentage.

A global percentage is satisfiable by testing the easy parts and says nothing about the change in front of a reviewer. The data exists in the Clover output and extracting it needs an XML parse and a schema nobody wants to depend on.

Why it happens

The built-in report writers were designed for a human reading a summary or for a service consuming a documented format, and neither audience wants a small JSON file keyed by path. Adding one is what an extension is for.

The fix

Bootstrapping an extension

final class JsonCoverageExtension implements Extension
{
    public function bootstrap(
        Configuration $configuration,
        Facade $facade,
        ParameterCollection $parameters,
    ): void {
        $facade->registerSubscriber(new WriteReportOnFinish(
            outputFile: $parameters->get('outputFile'),
            coverageEnabled: $configuration->hasCoverageReport(),
        ));
    }
}
<extensions>
  <bootstrap class="TurkerDevJsonCoverageJsonCoverageExtension">
    <parameter name="outputFile" value="build/coverage.json"/>
  </bootstrap>
</extensions>

The event that does not exist

the events an extension can subscribe to:

  TestPrepared, TestFinished, TestPassed,
  TestFailed, TestSuiteStarted, TestSuiteFinished,
  ApplicationStarted, ApplicationFinished, ...

what none of them carries: the coverage object.

coverage is collected by a driver the runner owns and
written by report writers the configuration names. the
event system is about test lifecycle rather than about
the runner's outputs, which is a coherent design and
means a coverage extension sits awkwardly beside it.
// so the extension does not receive coverage. it waits
// for ApplicationFinished and reads the PHP report
// that the configuration was asked to produce.
final class WriteReportOnFinish implements FinishedSubscriber
{
    public function notify(Finished $event): void
    {
        if (! is_file($this->phpReportPath)) {
            return;   // coverage was not enabled. not an error.
        }

        /** @var CodeCoverage $coverage */
        $coverage = require $this->phpReportPath;

        file_put_contents(
            $this->outputFile,
            json_encode($this->transform($coverage), JSON_THROW_ON_ERROR),
        );
    }
}

Requiring the PHP report is a workaround and it is stable, and it is the reason the package is pinned to a major version of the framework. Doing nothing when coverage is not enabled rather than throwing is the other decision — an extension that fails a test run because it could not write an optional report is worse than useless.

The output format

{
  "generated_at": "2026-04-15T09:41:02+00:00",
  "summary": { "lines": 11518, "covered": 8802, "percent": 76.41 },
  "files": {
    "src/Domain/Pricing/Calculator.php": {
      "percent": 97.2,
      "lines": { "41": 12, "42": 12, "48": 0, "49": 0 }
    }
  }
}

A line number mapped to an execution count, rather than to a boolean, is what makes the format useful for more than one question — a line executed once by one test is different from one executed by two hundred. The file is about four hundred kilobytes for eleven thousand lines, which is small enough to keep as an artefact per build.

Testing an extension

public function testItWritesAReportForACoveredFile(): void
{
    (new Process(['vendor/bin/phpunit', '--configuration',
        __DIR__ . '/fixtures/phpunit.xml']))->mustRun();

    $report = json_decode(
        file_get_contents(__DIR__ . '/fixtures/build/coverage.json'),
        true, flags: JSON_THROW_ON_ERROR,
    );

    self::assertSame(100.0, $report['files']['src/Covered.php']['percent']);
}
a subprocess rather than an in-process run, because
the extension registers against a runner that has
already started in the outer test.

  11 cases, about four seconds each, 44 seconds total.

which is a slow suite for a package whose entire job
is to produce a file, and is the only arrangement that
exercises the thing being tested.

What it enabled

$ ./bin/coverage-diff --base=origin/main --report=build/coverage.json
  changed lines:        188
  covered:              171
  uncovered:             17
  coverage of changes: 90.9%

  uncovered:
    src/Billing/Refund.php:41-48     an error branch
    src/Http/OrderController.php:88  a guard clause

Ninety per cent of changed lines is a question a reviewer can act on, and the seventeen uncovered lines are listed rather than aggregated — which makes the gate a prompt rather than an obstacle. The same report also drives the per-directory floors that replaced the global threshold in 2023.

Verifying it worked

$ vendor/bin/phpunit --coverage-php=build/cov.php
$ jq -r '.summary.percent' build/coverage.json
76.41
$ vendor/bin/phpunit --coverage-text | grep '^  Lines'
  Lines:   76.41% (8,802/11,518)
# the two agree, which is the whole correctness claim

$ jq -r 'keys' build/coverage.json
["files","generated_at","summary"]

$ npx ajv validate -s schema.json -d build/coverage.json
build/coverage.json valid

Agreement with the built-in text report is the only correctness assertion available, because the extension is reading the same coverage object the framework would. Publishing a JSON schema is what makes it consumable by anything other than our own script, and it is the part that turns a tool into a package.

What this costs

A package coupled to a major version of a test framework, by a mechanism — reading the PHP report file — that is not part of any documented extension API. A change to how that report is written breaks this, silently, in the direction of producing no output.

The subprocess test suite is also forty-four seconds for eleven assertions, which is slow enough that nobody will run it locally. It runs in CI and the local experience is that the package has no tests, which is the sort of thing that makes a contributor not write one.