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

Pretty Xml Laravel Package

simonschaufi/pretty-xml

A tiny PHP library for pretty printing and minifying XML with configurable indent size and character, since DomDocument can't configure indent distance. Install via Composer: simonschaufi/pretty-xml.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pin the right floor version.

composer require simonschaufi/pretty-xml:^3.1.1

Pin ^3.1.1, not ^3.0. Release 3.1.1 ("Reach a fixpoint when formatting text nodes") makes formatting idempotent — formatting an already-formatted string returns the same string. That property is what makes the package safe to use in middleware, queues, and test helpers where a payload may pass through more than one formatting layer.

There is nothing to register. No service provider, no config file, no facade, no publishable assets. The entire public surface is one class:

use PrettyXml\Formatter;

$formatter = new Formatter();          // defaults: 4-space indent
$formatter->setIndentSize(2);
$formatter->setIndentCharacter("\t");

$pretty = $formatter->format($xmlString);        // pretty print
$min    = $formatter->minify($xmlString);        // minify, comments stripped
$min    = $formatter->minify($xmlString, true);  // minify, comments kept

Where to look first. Read src/Formatter.php before anything else — the whole library is a few hundred lines and you will learn more about its edge cases from 10 minutes in that file than from the README. Then skim the releases list: 3.0.0 rewrote the algorithm entirely (output will differ from 2.x), and 3.1.1 fixed the text-node fixpoint. Both are relevant to whether your snapshot tests will churn.

First use case. Pretty-print an XML payload in a debug route or in Tinker so you can actually read it:

// routes/web.php (local only)
Route::get('/debug/xml', function () {
    $raw = Cache::get('last_soap_response');

    return response(
        '<pre>' . e((new Formatter())->format($raw)) . '</pre>'
    );
})->middleware('can:debug');

Note the e() — the README uses htmlspecialchars(), and you should never skip it. format() returns raw XML; dumping it into a browser unescaped will render tags as markup and mangle the output you were trying to inspect.

The second use case, and the one that earns its keep day-to-day, is minifying XML before it goes over the wire or into a cache. Pretty-printing is a developer convenience; minification is the production behavior.

Implementation Patterns

Bind a configured singleton

Don't new Formatter() at every call site — the indent settings are per-instance mutable state, and you want them consistent. Register it once in AppServiceProvider:

// app/Providers/AppServiceProvider.php
use PrettyXml\Formatter;

public function register(): void
{
    $this->app->singleton(Formatter::class, function () {
        return (new Formatter())
            ->setIndentSize(2)
            ->setIndentCharacter(' ');
    });
}

Because setIndentSize() and setIndentCharacter() mutate and return the instance (fluent), configure it at construction and never touch it again. See the Octane gotcha below for why.

Middleware for XML responses

The canonical integration: normalize any XML response leaving the app.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Response;
use PrettyXml\Formatter;

class FormatXmlResponse
{
    public function __construct(private Formatter $formatter) {}

    public function handle($request, Closure $next)
    {
        $response = $next($request);

        if (! $this->isXml($response)) {
            return $response;
        }

        // Guard even though 3.1.1 is idempotent — cheap insurance,
        // and it makes the log line unambiguous.
        if ($response->headers->has('X-XML-Formatted')) {
            return $response;
        }

        $xml = $response->getContent();

        $response->setContent(
            config('app.debug')
                ? $this->formatter->format($xml)
                : $this->formatter->minify($xml)
        );

        $response->headers->set('X-XML-Formatted', '1');

        return $response;
    }

    private function isXml($response): bool
    {
        return $response instanceof Response
            && str_contains((string) $response->headers->get('Content-Type'), 'xml');
    }
}

Two design choices worth defending. First, format() only when app.debug is on — pretty-printing in production buys the client nothing and costs CPU and bytes. Second, check Content-Type rather than assuming; XML leaks out of SOAP clients, sitemaps, RSS feeds, and third-party SDK responses, and you do not want to touch a response that merely contains the string <xml.

Wrapping non-string XML sources

The formatter only accepts strings. Every other XML representation has to be serialized first — this trips people up constantly:

$formatter = app(Formatter::class);

$formatter->format($simpleXmlElement->asXML());   // SimpleXMLElement
$formatter->format($domDocument->saveXML());      // DOMDocument
$formatter->format((string) $response->getContent());

For a SimpleXMLElement, asXML() already includes the XML declaration, so don't prepend one.

An Artisan command for fixtures and debugging

A genuinely useful day-to-day tool — pretty-print XML files in place or to stdout:

class PrettyXmlCommand extends Command
{
    protected $signature = 'xml:pretty {path : File or "-" for stdin} {--minify} {--size=4}';

    public function handle(Formatter $formatter): int
    {
        $formatter->setIndentSize((int) $this->option('size'));

        $xml = $this->argument('path') === '-'
            ? stream_get_contents(STDIN)
            : file_get_contents($this->argument('path'));

        $this->line($this->option('minify')
            ? $formatter->minify($xml)
            : $formatter->format($xml));

        return self::SUCCESS;
    }
}

This is the pattern that replaces the "echo into a <pre> in a scratch route" habit, and it composes with git diff when you're normalizing a pile of committed XML fixtures.

Logging and exception context

XML in a log line is unreadable on one line. Minify for the log, pretty-print when you're on the console:

Log::error('SOAP fault', [
    'body' => app(Formatter::class)->format($request->getContent()),
]);

If you write this to a file-based log, format() output spans many lines and breaks single-line log parsing — use minify() there and reserve format() for Log::stack channels you only read interactively, or for Tinker.

Test patterns

Three assertions worth having, in order of value:

// 1. Idempotency — lock in the 3.1.1 guarantee
public function test_formatting_is_idempotent(): void
{
    $f = new Formatter();
    $once = $f->format($this->fixture('order.xml'));

    $this->assertSame($once, $f->format($once));
}

// 2. Semantic equivalence — formatting must not change the document
public function test_pretty_print_preserves_structure(): void
{
    $f = new Formatter();
    $original = $this->fixture('order.xml');

    $this->assertXmlStringEqualsXmlString($original, $f->format($original));
    $this->assertXmlStringEqualsXmlString($original, $f->minify($original));
}

// 3. Wire-format assertions in feature tests
public function test_endpoint_returns_minified_xml(): void
{
    $response = $this->get('/api/feed.xml');

    $response->assertHeader('Content-Type', 'application/xml');
    $this->assertStringNotContainsString("\n    ", $response->getContent());
}

assertXmlStringEqualsXmlString() is the important one: it compares parsed trees rather than bytes, so it survives indent changes and will actually catch a real corruption bug rather than failing because you switched from 4 to 2 spaces.

Minify before caching, format on read

If you cache XML, cache the minified form and format on the way out for humans:

$xml = Cache::remember("feed:{$id}", now()->addHour(), fn () => $formatter->minify($xml));

Minification shrinks the payload that sits in Redis and removes whitespace that would otherwise vary between runs.

Gotchas and Tips

The API is format(), not prettyPrint(). Some vendor-generated integration notes floating around reference a PrettyXml::prettyPrint() facade method. That does not exist. There is no facade in this package at all. If you want one, write it yourself:

class PrettyXml extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return \PrettyXml\Formatter::class;
    }
}

Mutable indent state + Octane = cross-request leakage. If you bind the formatter as a singleton (as shown above) and then call setIndentSize(8) inside a controller, that setting persists for the worker — potentially bleeding into the next request. Two fixes: configure once at bind time and never mutate, or resolve a fresh new Formatter() at any call site that needs non-default indentation. This is the single most likely bug you will introduce with this package.

Malformed XML may produce garbage instead of throwing. The formatter is string-based and does not validate. Feed it a truncated payload and you can get output that looks plausible but is structurally broken. Validate first when the source is untrusted:

$prev = libxml_use_internal_errors(true);
$valid = simplexml_load_string($xml) !== false;
libxml_clear_errors();
libxml_use_internal_errors($prev);

abort_unless($valid, 502, 'Upstream returned malformed XML');

Whitespace-significant content is at risk. Inline text, mixed content, <pre>-like elements, and CDATA are where formatters historically go wrong — and 3.1.1 was specifically a fix in the text-node handling. If you format payloads where a text node's leading/trailing whitespace is semantically meaningful, test against real fixtures rather than synthetic ones. When in doubt, minify() instead of format(): stripping inter-element whitespace is usually safe, reindenting is not.

Never pretty-print in production by default. Gate on config('app.debug'), a request header, or an explicit query param. The cost is CPU plus bytes for zero client benefit, and there is a nonzero risk of perturbing whitespace-sensitive consumers. Minify is the production default.

Namespaces, DOCTYPE, and processing instructions need explicit testing. xmlns declarations and qualified names generally survive, but DOCTYPE blocks and PIs are the kind of structural edge case the 3.1.1 fix does not claim to cover. If your payloads carry a DOCTYPE (common in legacy SOAP and in document-oriented feeds), put a fixture in your test suite before you ship.

Comments are dropped by default in minify(). The second argument preserves them. If your XML carries meaningful comments — license headers, generated-by markers, section annotations — pass the flag, or you will silently delete them and nobody will notice until a downstream consumer breaks.

Upgrading 2.x → 3.x changes output. 3.0.0 replaced the algorithm outright. Any byte-comparison snapshot test on formatted XML will fail on upgrade. Convert those snapshots to assertXmlStringEqualsXmlString() before you upgrade, and add the idempotency test at the same time.

Benchmark large payloads before you commit to middleware. The algorithm is string manipulation, not a streaming parser. For sub-100KB responses the overhead is negligible; for multi-megabyte feeds it is not. Measure with your actual payloads, and if they're large, format asynchronously (queue + cache) rather than per-request.

Always escape when rendering in HTML. e($formatter->format($xml)), every time. The README demonstrates this with htmlspecialchars(); skipping it turns your debug view into an XSS vector if the XML is attacker-influenced and a broken view even when it isn't.

Guard against double application. 3.1.1 makes it safe, but a header flag makes the behavior observable in logs and prevents surprise formatting of responses that legitimately contain XML fragments. Cheap, and it documents intent.

Extension points are effectively nil — and that's fine. No interfaces, no events, no config. Your extension point is a thin wrapper class in app/Support/ that owns the indent configuration and exposes the methods your app actually calls. That wrapper is also the seam that lets you swap in a DOMDocument fallback if the package is ever abandoned. Given that it's MIT-licensed and a few hundred lines, forking is a realistic fallback plan rather than a theoretical one — worth noting in your ADR.

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.
mydnic/volet-feature-board
wmde/fundraising-phpcs
friendlylabs/surveyr
mg-techlegend/laravel-notify-africa
islamabdurahman/laravel-sms-service
dmhendricks/b2-sdk-php
aplicativa/laravel-admin
ezavalishin/vkma
jackardios/laravel-json-api-paginate
viral/laravel-adminpanel
dieppk/log-viewer
daimakuai-ext/helpers
codexshaper/php-blade-view
abraxas81/charts
adamlien/laravel-cas
quimgc/tasks
sistem-atc/bank-sdk
core45/laravel-cookieconsent
projectsend/community-modules
ovac/laravel-hubtel-payment