andreas-weber/php-junit-merge
A PHP tool for merging multiple JUnit XML test reports into one file, simplifying consolidation of results from parallel test runs and CI jobs. Useful for build pipelines that need a single combined JUnit report.
andreas-weber/php-junit-merge is a small CLI tool that concatenates multiple JUnit XML reports (the <testsuite>/<testcase> structure PHPUnit, Pest, Paratest, and Dusk emit) into a single file. That is its entire job — it is not a Laravel package, so there is no service provider, no config file, and nothing to publish.
Install it as a dev dependency so it lands next to PHPUnit:
composer require --dev andreas-weber/php-junit-merge
The binary is exposed through Composer's bin proxy:
vendor/bin/php-junit-merge --in=build/junit --out=build/junit-merged.xml
--in accepts a file or a directory of XML reports; --out is the merged result. Run vendor/bin/php-junit-merge --help first — the README is thin and flag names/behaviour are the fastest thing to verify against the version you actually installed.
First useful use case: a Laravel app that runs PHPUnit unit tests, a separate Feature suite, and Dusk browser tests in CI. Each job writes its own junit.xml; a final job merges them into one artifact that Jenkins/GitLab/SonarQube can consume. Wire it up by configuring PHPUnit logging per suite:
<!-- phpunit.xml -->
<logging>
<junit outputFile="build/junit/unit.xml"/>
</logging>
Then point the merger at build/junit.
Composer script for local runs. Keep the merge reproducible outside CI:
"scripts": {
"test:merge": [
"@php vendor/bin/php-junit-merge --in=build/junit --out=build/junit-merged.xml"
]
}
Clean-then-merge in CI. The most common failure is merging yesterday's reports. Always remove the input directory before the test run, and gate the merge step so it executes even when tests fail (if: always() in GitHub Actions, when: always in GitLab):
- run: rm -rf build/junit && mkdir -p build/junit
- run: php artisan test --log-junit build/junit/app.xml # or vendor/bin/phpunit
- if: always()
run: vendor/bin/php-junit-merge --in=build/junit --out=build/junit-merged.xml
Fan-in across a matrix. When parallel jobs each upload junit.xml as an artifact, download them into a single directory in a downstream job (actions/download-artifact with merge-multiple: true, or artifacts:paths in GitLab), then merge once. This is the pattern that makes GitLab's artifacts:reports:junit and Jenkins' JUnit plugin behave consistently, since both prefer one report per pipeline.
Parallel testing. Paratest and Pest --parallel produce per-worker files plus a wrapper report. Decide which set you want: merging the per-worker files gives per-test granularity; merging the wrapper file alongside single-process suites is usually enough. Don't merge both, or every test appears twice.
Programmatic use. The command is a standard Symfony Console command, so if you need it inside PHP (a custom Artisan command, a queue job that assembles reports) instantiate a Symfony\Component\Console\Application, register the command, and use CommandTester or Application::run(). Read src/ before doing this — the internal Merger API is not a documented public contract, so keep the coupling behind one of your own wrapper classes.
Reporting targets. Merged output feeds SonarQube's sonar.php.tests.reportPaths, Jenkins' JUnit publisher, and CodeClimate-style tooling. Keep the merged file in build/ (git-ignored) and upload it as an artifact; never commit it.
composer why-not before adopting, and expect to fork if your app is on Symfony 7 components. The merge logic is small enough that a ~50-line DOMDocument implementation is a viable fallback.--in at your project root. Directory scanning picks up XML by extension, and phpunit.xml, phpunit.xml.dist, and composer.xml-style files will be ingested as if they were reports and produce garbage or a hard failure. Dedicate an output directory.tests/failures/errors counts and durations are arithmetic on the source suites. If a report is malformed or truncated, you get wrong totals silently rather than an error.<properties> blocks are out of scope; combine coverage separately (e.g., phpcov merge).ls build/junit/*.xml in CI or check the exit code explicitly.DOMDocument. Fine for thousands of tests, risky for very large Dusk suites on constrained CI runners; raise memory_limit if needed.How can I help you explore Laravel packages today?