league/phpunit-coverage-listener
A PHPUnit listener from The League of Extraordinary Packages that hooks into test runs to capture code coverage and surface it for CI reporting. Use it to keep coverage visible alongside test results, monitor trends, and make untested code easier to spot while helping teams enforce coverage threshol...
This is a CI-side utility, not an application dependency. It hooks into PHPUnit's test-listener API, grabs the Clover coverage report PHPUnit just generated, and POSTs it to a URL you control. There is no service provider, no facade, and nothing Laravel-specific — treat it as a build tool.
Install (with eyes open — see Gotchas):
composer require --dev league/phpunit-coverage-listener
Where to look first, in this order:
README.md — the only source of truth for constructor/config keys, since the API predates modern conventions.src/Listener.php — implements the PHPUnit listener interface; this is what <listeners> instantiates.src/Config.php (or equivalent options bag) — target_url, target_dir, and format selector.src/Formatter/ — the Clover-to-payload transformer.src/Hook/ — the single extension point most people actually need.First use case — the one this package exists for: after a CI run, ship your coverage number to a dashboard so the team can see coverage per branch without buying a SaaS.
Minimal wiring, in phpunit.xml:
<phpunit>
<testsuites>...</testsuites>
<logging>
<log type="coverage-clover" target="build/coverage/clover.xml"/>
</logging>
<listeners>
<listener class="\League\PHPUnitCoverageListener\Listener">
<arguments>
<array>
<element key="target_url"><string>https://ci.example.com/coverage</string></element>
<element key="target_dir"><string>build/coverage</string></element>
<element key="format"><string>clover</string></element>
</array>
</arguments>
</listener>
</listeners>
</phpunit>
Then:
vendor/bin/phpunit --coverage-clover build/coverage/clover.xml
If your endpoint replies 200 and you see a row in your dashboard, you're done. If nothing arrives, the listener never fired — see Gotchas.
1. Run it only in CI, never locally. Locally you want fast feedback, not a network round-trip on every run. Keep the listener registered but point target_url at an env var, and short-circuit the Hook when the env var is empty:
class CiOnlyHook implements \League\PHPUnitCoverageListener\Hook\HookInterface
{
public function __invoke($payload)
{
if (! getenv('COVERAGE_ENDPOINT')) {
throw new \RuntimeException('Coverage endpoint not configured; skipping upload.');
}
return $payload;
}
}
Simpler still: keep a separate phpunit.ci.xml that includes the <listeners> block, and a plain phpunit.xml that doesn't. This is the cleanest pattern and avoids fighting the listener lifecycle.
2. Enrich the payload with build metadata via a Hook. The stock payload is coverage data only. Your dashboard almost certainly needs branch, commit SHA, and build number. The Hook is the designed seam for this — decorate the payload before it hits cURL:
$payload['branch'] = getenv('GITHUB_REF_NAME');
$payload['commit'] = getenv('GITHUB_SHA');
$payload['build_url'] = getenv('GITHUB_SERVER_URL') . '/' . getenv('GITHUB_REPOSITORY') . '/actions/runs/' . getenv('GITHUB_RUN_ID');
$payload['php'] = PHP_VERSION;
3. Pair it with a Laravel ingest endpoint. The natural home for the receiving side of this is a small POST /internal/coverage route in an existing internal Laravel app. Validate a shared secret, store the summary in a coverage_reports table (branch, commit, percentage, created_at), and serve a per-branch trend from a simple Blade/Livewire page. That's the whole feature — no external vendor.
4. Use the Formatter class directly if you outgrow the listener. The Clover parser is the genuinely reusable part. If you migrate off the PHPUnit listener (you will, eventually), you can keep the parsing behaviour by calling the formatter yourself from a small Artisan command:
// app/Console/Commands/UploadCoverage.php
$xml = simplexml_load_file(base_path('build/coverage/clover.xml'));
$metrics = $xml->project->metrics; // statements, coveredstatements, elements, coveredelements
$percent = $metrics['statements'] > 0
? round($metrics['coveredstatements'] / $metrics['statements'] * 100, 2)
: 0.0;
Http::withToken(config('services.coverage.token'))
->post(config('services.coverage.url'), [
'branch' => $branch, 'commit' => $sha, 'percentage' => $percent,
]);
This is the pragmatic exit ramp: same outcome, zero dependence on a 2013 package, works on PHPUnit 10/11.
5. Gate quality in the same job. Once coverage is flowing, add php artisan test --coverage --min=70 (or Pest's --min) as a separate CI step so the build fails on regression, rather than discovering it on a dashboard the next morning.
It almost certainly won't run on your PHPUnit. PHPUnit 10 removed both TestListener and the <listeners> config element; PHPUnit 9 deprecated them. This package was last released in October 2013 and targets the PHPUnit 3.x listener interface (PHPUnit_Framework_TestListener). On a modern Laravel stack you'll get "class not found", a silent no-op, or a fatal on a missing interface. If you're on PHPUnit 9, aliasing the old interface names is theoretically possible but not worth it. Realistically: use this package as a design reference, not a dependency.
No coverage collected = no listener activity. The listener reads the file PHPUnit writes. If you didn't pass --coverage-clover and configure <logging><log type="coverage-clover">, there's nothing to send and it fails quietly. Xdebug 3 also needs XDEBUG_MODE=coverage — the single most common cause of "the listener doesn't fire" is that coverage mode is off in the container.
cURL timeouts on real projects. Clover XML for a mid-sized Laravel app is multiple megabytes. Default cURL timeouts and post_max_size on the receiving end will bite you. Either send a summary payload (percentages + per-file counts) instead of the full XML, enable gzip, or raise the timeout explicitly. Test against your largest real report, not a toy one.
Failures are silent by default. A non-2xx response from your endpoint generally won't fail the build. Wrap the Hook to log the response code, and consider throwing on non-2xx so CI surfaces it. Otherwise you'll ship weeks of missing coverage data before anyone notices.
Keep credentials out of phpunit.xml. This config is committed. Put the token in an env var and reference it, or use a CI-only config file that's generated at build time. Treat the target URL as a secret if it's an unauthenticated ingest endpoint.
Pin the CI image. PHP 8+ deprecation noise from 2013-era code can flood logs; run it in the same job where you control the PHP version, and pin Xdebug/PCOV versions so coverage numbers don't jitter between runs.
Extension points are thin. There's really only the Hook (payload decoration) and the Formatter (parsing). There is no retry, no batching, no auth handling beyond whatever URL you bake in. If you need any of that, you're writing it yourself — which is a strong argument for the 15-line Http::post version above.
The alternatives are strictly better today. php-coveralls/php-coveralls, Codecov's uploader, and SonarQube all do this job with maintained code, retries, and token auth. Choose one of those, or roll the small Artisan command — this package's only remaining value is as a clear, readable example of how a coverage listener is structured.
How can I help you explore Laravel packages today?