Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Junit Xml Laravel Package

n98/junit-xml

n98/junit-xml is a lightweight PHP library for building JUnit XML reports. It offers a simple API to define test suites and cases with failures, errors, skipped tests, and timing, making it easy to generate CI-friendly XML output from custom test runners.

View on GitHub
Deep Wiki
Context7

Getting Started

n98/junit-xml is a tiny write-only serializer: you hand it test-suite/case metadata and it builds a JUnit XML DOM you can dump to a file or string. It is not a test runner, PHPUnit plugin, or XML parser — it is the last mile between "I have results in memory" and "CI can read a JUnit report."

Install and confirm the namespace:

composer require n98/junit-xml
# namespace: N98\JUnitXml

Your first use case is almost always a console command or job that already knows the outcome of some custom check (migrations, smoke tests, a home-grown acceptance suite) and needs to emit a report:

use N98\JUnitXml\Document;

$doc = new Document();

$suite = $doc->addTestSuite();
$suite->setName('App\Tests\Checkout');
$suite->setTests(1);

$case = $suite->addTestCase();
$case->setName('it charges the card');
$case->setClass('App\Tests\Checkout');   // attribute naming varies – see Gotchas
$case->setTime(0.124);

$failure = $case->addFailure();
$failure->setType('AssertionFailed');
$failure->setMessage('Expected 200, got 500');

$doc->save(storage_path('app/reports/junit.xml'));

Look here first, in this order:

  1. src/N98/JUnitXml/Document.php — extends DOMDocument; addTestSuite() plus inherited save() / saveXML().
  2. src/N98/JUnitXml/TestSuiteElement.php — the counters (tests, failures, errors, skipped, time) you must populate by hand.
  3. src/N98/JUnitXml/TestCaseElement.php — addFailure(), addError(), addSkipped(), and the attribute setters.
  4. src/N98/JUnitXml/TestFailureElement.php / TestErrorElement.php — type and message, with the human-readable detail in the element's text content.

The whole surface is a few hundred lines. Skim all of it before writing your wrapper; the setters mirror JUnit attribute names but aren't perfectly consistent (class vs classname, for example), so trust the source over the docs.

Implementation Patterns

Wrap it in a reporter service. Don't sprinkle new Document() through your codebase. Build one class that accepts your domain results and returns either a path or an XML string:

final class JUnitReporter
{
    /** @param TestResult[] $results */
    public function build(array $results, string $suiteName): Document
    {
        $doc   = new Document();
        $suite = $doc->addTestSuite();
        $suite->setName($suiteName);
        $suite->setTests(count($results));
        $suite->setFailures(count(array_filter($results, fn ($r) => $r->failed())));
        $suite->setErrors(count(array_filter($results, fn ($r) => $r->errored())));
        $suite->setTime(array_sum(array_map(fn ($r) => $r->duration(), $results)));

        foreach ($results as $result) {
            $case = $suite->addTestCase();
            $case->setName($result->name());
            $case->setTime($result->duration());

            if ($result->failed()) {
                $case->addFailure()
                     ->setMessage($result->message())
                     ->setType($result->exceptionClass());
            }
        }

        return $doc;
    }
}

Map once, from a DTO. Keep an internal TestResult value object (name, status, duration, message, trace) and translate it in exactly one place. When CI changes what it wants, you edit the reporter, not every call site.

One suite per class, one document per run. Most consumers (GitLab, Jenkins, Bitbucket) group by <testsuite name="...">. If you're reporting per-test-class, push each class as its own suite into a single document. For parallel test shards, write one file per shard and let the CI parser merge them — do not try to append to the same file from concurrent processes.

Persist through Laravel's filesystem, not save() directly. $doc->save() writes with the process user's permissions and silently returns false on failure. Prefer Storage::disk('local')->put($path, $doc->saveXML()) so directory creation, permissions, and fake-disk testing all work:

Storage::disk('reports')->put(
    "junit/{$runId}.xml",
    $doc->saveXML()
);

Test the reporter itself. Load the generated XML with DOMDocument/SimpleXML in a test and assert structure, or snapshot a small fixture. You're generating a machine contract — a one-character attribute change breaks CI silently.

Enrich failures deliberately. Put the assertion diff or stack trace in the failure element's text content (not an attribute), and use addSystemOut() / addSystemErr() on the test case for captured logs. Set file and line when you have them; GitLab and Jenkins use them to link back to source.

Laravel integration points that make sense: an artisan command (php artisan tests:junit), a queued job for long-running suites, or a phpunit.xml listener in projects that already shell out to PHPUnit. Resolve the reporter from the container as a singleton so the timestamp/hostname are consistent across suites in one run.

Gotchas and Tips

  • Counters are yours to maintain. The library will not count tests, failures, or total time for you. Forgetting setTests() produces a report that many parsers consider empty or invalid — this is the single most common bug.
  • save() fails quietly. It returns false (and raises a PHP warning) if the directory doesn't exist or isn't writable. Always create the target directory first and check the return value, or use saveXML() + Storage::put().
  • Nothing validates against the JUnit XSD. You can emit a structurally wrong document happily. Validate in your test suite (libxml + the common junit.xsd), and check against the actual consumer — GitLab is stricter about classname than Jenkins.
  • Attribute naming is inconsistent. Some elements expose classname, others class; the time attribute should be a decimal string in seconds, not an integer or a DateInterval. Confirm every setter in the element class before relying on it — the API is small enough that reading it beats guessing.
  • Failure vs. error matters. Use <failure> for assertion mismatches and <error> for exceptions/crashes; CI dashboards treat them as different buckets, and mixing them makes flaky-test triage useless.
  • DOMDocument escapes text for you. Never hand-build XML strings or interpolate into message. Strip control characters and invalid UTF-8 from captured output first, otherwise saveXML() produces bytes your parser will reject.
  • Timestamps are strings you format. JUnit expects ISO-8601-ish timestamp on the suite; set it once per run, not per case, or reports scattered across time zones get confusing.
  • Memory scales with the report. A DOMDocument holds the whole tree; for tens of thousands of cases write per-suite files and concatenate results downstream rather than building one giant document.
  • Extension points are just inheritance. Document and the *Element classes are plain subclasses you can extend if you need custom attributes (<properties>, arbitrary metadata). There's no plugin or event system — subclassing is the intended route.
  • Maturity risk. Low star count, zero dependents reported, and a license string of NOASSERTION — verify the actual license in composer.json/LICENSE before shipping it in a product, and pin the version. It's small enough to vendor or reimplement in an afternoon if it ever goes unmaintained.
  • Don't reach for this if PHPUnit already writes JUnit. phpunit --log-junit covers the standard case. This library earns its place only when your results come from something PHPUnit doesn't run.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource