d0x2f/clover-merge
d0x2f/clover-merge merges multiple Clover XML coverage reports into one consolidated file. Designed for Laravel/PHP projects, it helps aggregate coverage from parallel test runs and CI jobs so results can be passed cleanly to dashboards and reporting tools.
d0x2f/clover-merge is a small, framework-agnostic utility that takes two or more Clover XML files (the format PHPUnit produces with --coverage-clover) and writes a single combined Clover document. It solves the "I ran my test suite in shards / in parallel and now my coverage tool only accepts one file" problem.
v1.0.5 adds a coverage gate. The new --enforce/-e flag makes the CLI exit non-zero when the merged coverage falls below a threshold, which turns the merge step into a CI quality gate as well as a concatenation step.
Install (dev only):
composer require --dev d0x2f/clover-merge
Where to look first:
README.md — small surface, but the CLI flags (-e, -o, positional inputs) are the actual day-to-day API now.src/ — the whole implementation is essentially one class wrapping DOMDocument, plus a thin CLI entry point.composer.json — confirm the bin entry (you want vendor/bin/clover-merge) and the PHP constraint before planning around it.First use case — merge two local runs and gate at 80%:
vendor/bin/phpunit --testsuite=Unit --coverage-clover build/clover-unit.xml
vendor/bin/phpunit --testsuite=Feature --coverage-clover build/clover-feature.xml
mkdir -p build
vendor/bin/clover-merge -e 80 -o build/clover.xml build/clover-unit.xml build/clover-feature.xml
Expected output (from the release notes):
Files Discovered: 2
Final Coverage: 4/5 (80.00%)
Exit code 0 when the threshold is met, 1 when it isn't. The output file is a normal Clover file you can feed straight to Codecov, Coveralls, SonarQube, or phpunit --coverage-text pipelines. If you prefer the class API, it's still there — read the class for the exact signature, it takes a minute.
1. CI matrix sharding, with the gate in the merge job. Split tests across jobs, each emits its own clover as an artifact, and the final job merges and enforces.
# .github/workflows/coverage.yml (abridged)
jobs:
test:
strategy:
matrix: { shard: [1, 2, 3, 4] }
steps:
- run: vendor/bin/phpunit --coverage-clover build/clover-${{ matrix.shard }}.xml
- uses: actions/upload-artifact@v4
with: { name: clover-${{ matrix.shard }}, path: build/clover-*.xml }
coverage:
needs: test
steps:
- uses: actions/download-artifact@v4
with: { path: build, pattern: clover-* }
- run: composer install --no-interaction
- run: mkdir -p build/final
- run: vendor/bin/clover-merge -e 85 -o build/final/clover.xml build/clover-*.xml
- uses: codecov/codecov-action@v4
with: { files: build/final/clover.xml }
Note the ordering: -e exits non-zero, so anything you want to run regardless of the gate (uploading the artifacts for debugging, posting the number to a PR comment) must run before the merge step or use if: always().
2. Wrap it in an Artisan command — especially now, so the threshold lives in config. The exit-code contract is easy to lose inside a wrapper; be deliberate about propagating it.
// app/Console/Commands/MergeClover.php
protected $signature = 'clover:merge {--out=build/clover.xml} {--min=}';
public function handle(): int
{
$inputs = glob(base_path('build/clover-*.xml')) ?: [];
if ($inputs === []) {
$this->error('No clover files found.');
return self::FAILURE;
}
$min = $this->option('min') ?? config('coverage.min');
$args = array_filter([$min !== null ? "-e {$min}" : null, "-o {$this->option('out')}"]);
$cmd = implode(' ', array_map('escapeshellarg', [base_path('vendor/bin/clover-merge'), ...$args, ...$inputs]));
passthru($cmd, $exitCode);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
Run it from the vendor binary rather than reimplementing the gate in PHP — duplicating the arithmetic is how the wrapper and the tool end up disagreeing.
3. Composer script for local parallelism. If you use ParaTest, each worker needs its own clover file; merge afterwards and gate locally so you find out before pushing.
"scripts": {
"test:coverage": [
"mkdir -p build",
"php artisan test --parallel --coverage-clover=build/clover-1.xml",
"clover-merge -e 85 -o build/clover.xml build/clover-*.xml"
]
}
4. Pick one gate, not two. Before v1.0.5 you'd have used Codecov/Coveralls' threshold settings or a homemade check. Now you can gate in the build itself. Choosing both means two different percentages failing at two different stages with two different messages — pick the one that owns the number and let the other just report.
5. Normalize paths before merging. If shards run from different working directories (common in containers or matrix jobs), file paths inside <file name="..."> drift and the merged report looks like it covers two copies of your app — which also distorts the enforced percentage. Either always invoke PHPUnit from the repo root, or post-process the XML to rewrite absolute paths to repo-relative ones.
6. Upload once, everywhere. Run the merge before every consumer (Codecov, Coveralls, Sonar, text reports). The merged file — the one that passed the gate — is the canonical build artifact.
-e gates the total, not any single file. 4/5 (80.00%) is aggregate line coverage. A shard that silently produced an empty clover lowers your total and trips the gate; that's usually the desired signal, but it means flaky test discovery now fails CI with a coverage error rather than a test error. Check Files Discovered: in the output before blaming your tests.set -e, make, or a Composer script chain, a failed gate aborts the rest of the script. That's the point — just don't put the upload after the enforced merge and then wonder why Codecov has no report on red builds.90, and the comparison is rendered as 80.00% < 90.00%. Pass a plain number; if you compute it from a variable, guard against empty strings (an empty -e argument may be parsed as the next token) and against locale-formatted decimals with commas.Files Discovered: / Final Coverage: lines are stable enough to grep if you want to surface the number in a job summary, but treat them as display output, not a contract — parse the merged XML if you need machine-readable metrics.<file> nodes) when nothing is covered. Validate with xmllint --noout build/clover-*.xml and skip empties before merging; with -e active, a single empty input can drag the merged total under the line.<file> may appear in multiple inputs. Verify the merged hit counts against a single full run before trusting the number — double counting or max-of-hits semantics shift the enforced percentage in both directions.app/Models/User.php, ./app/Models/User.php, and /home/runner/work/app/app/Models/User.php are three different "files" to a coverage reporter. Keep the working directory pinned across shards.mkdir -p for you — create build/ (or your chosen path) in the workflow/script first.-e and diff the inputs with xmllint --format before blaming the merger. Most "merge bugs" are malformed or path-inconsistent inputs; the enforce flag just makes them louder.<metrics>), wrap the class and post-process the resulting DOMDocument rather than editing vendor/.composer.json for the PHP constraint, and keep a ~40-line DOMDocument-based merger plus a one-line percentage check in app/Support/ as a fallback if a future PHP upgrade makes it uninstallable. The Clover schema and the exit-code contract are stable enough that the swap is low-risk.require-dev only; it belongs in your coverage pipeline, never in the runtime autoload path.How can I help you explore Laravel packages today?