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

Xml Wrangler Laravel Package

saloonphp/xml-wrangler

XML Wrangler is a PHP package that provides a simple, elegant API for reading and writing XML. Built on SimpleXML, it adds namespace support, fluent methods, and clean error handling, and pairs perfectly with Saloon to parse and build XML API responses with ease.

View on GitHub
Deep Wiki
Context7

Getting Started

Install it as a normal Composer dependency — there is nothing to register, no service provider, no config file:

composer require saloonphp/xml-wrangler

Then read two things in this order: the README (it is short and example-driven), and the tests/ directory — the test suite is the real spec for edge cases like attributes, CDATA, namespaces and repeated elements.

Where it fits first. The natural entry point in a Laravel app is inside a Saloon connector or a request's createDtoFromResponse(): an XML API responds, and instead of simplexml_load_string() you build a reader and pull values out.

use Saloon\XmlWrangler\XmlReader;

$response = $this->send(new GetCustomerRequest($id));

$reader = XmlReader::fromResponse($response);

$name = $reader->element('name')->first()->getContent();
$attributes = $reader->element('customer')->first()->getAttributes();

First real use case. Pick one XML-speaking partner endpoint you already have. Read it with XmlReader and map it into an existing DTO. Ship that. Don't start with the writer — reading is where the win is most visible, and it proves the array/query model to the team before you commit to generating payloads with it.

Implementation Patterns

Reader → DTO mapping in a connector

Keep the reader out of your domain layer. Parse at the boundary, map immediately, and let the rest of the app deal in DTOs. This is the pattern that makes XML a non-event six months later.

$reader = XmlReader::fromResponse($response);

$orders = $reader->element('order')->get()->map(fn ($element) => new OrderData(
    id: (int) $element->getAttributes()['id'],
    reference: (string) $element->element('reference')->first()->getContent(),
    total: (int) round(((float) $element->element('total')->first()->getContent()) * 100),
))->all();

element() is sugar over a query; first() gives you one node (or null). Reach for sole() when you want the contract enforced — it throws if there are zero or multiple matches, which catches malformed partner payloads far earlier than a null check three lines later.

XPath queries for anything non-trivial

element() only walks by tag name. The moment you need predicates or nesting, use the query API:

$reader->query('//items/item[@status="shipped"]')->first();
$reader->query('//customer/address')->get();

For very large documents, ->lazy() on a query returns a LazyQuery you can iterate without materialising every result up front. Treat this as an optimisation to spike, not a default — measure memory against a real payload before you build a batch job on it.

Namespaced documents (SOAP, AS4, most enterprise feeds)

A //Body query silently returns nothing under a default namespace. Register the prefix first:

$reader = XmlReader::fromResponse($response)
    ->withNamespace('soap', 'http://schemas.xmlsoap.org/soap/envelope/')
    ->withNamespace('ns2', 'urn:example:orders');

$body = $reader->query('//soap:Body')->first()->getContent();

If the prefixes are inconsistent between partners or versions, ->removeNamespaces() strips them so you can query by plain tag name. That's the pragmatic escape hatch — use it in integration code, not as a habit.

Writing payloads

The writer is closure-based and mirrors the document shape, which reads well as long as you keep any single document under a screenful:

use Saloon\XmlWrangler\XmlWriter;

$xml = XmlWriter::make()
    ->element('order', function (XmlWriter $writer) {
        $writer->element('reference', 'ORD-1001');
        $writer->element('lines', function (XmlWriter $writer) {
            $writer->element('line', function (XmlWriter $writer) {
                $writer->attributes(['sku' => 'ABC-1', 'qty' => 2]);
                $writer->element('price', '19.99');
            });
        });
    })
    ->setXmlStandalone(true)
    ->write();

Then send it as the body with an explicit XML content type:

$this->body($xml)->headers(['Content-Type' => 'application/xml; charset=UTF-8']);

Keep one internal facade

Even a small wrapper pays off. Put a Xml helper or trait around the package so that the day a partner needs something the package can't do, you change one file:

final class Xml
{
    public static function read(Response|string $source): XmlReader { /* ... */ }
    public static function write(Closure $build): string { /* ... */ }
}

That wrapper is also the right place to put shared concerns: trimming whitespace, coercing encodings, logging the raw body on parse failure.

Testing

Store real partner payloads as fixtures under tests/Fixtures/, and assert on the mapped DTOs rather than on raw arrays. For output, compare against a golden file or use PHPUnit's assertXmlStringEqualsXmlString() so formatting differences don't produce false failures. Contract tests against a saved fixture are the highest-value test you can write here — partner payloads are irregular in ways nobody documents.

Gotchas and Tips

  • It reads and writes; it does not validate. There is no XSD validation in this package. If a partner contract is schema-bound, pair it with veewee/xml's schema validator (which the writer/reader already sits on) or a dedicated validator before you trust the output.
  • Everything is a string. Values coming out of the reader are strings regardless of how numeric the payload looks. Cast explicitly and defensively — (int), (float), and be careful with decimal money (multiply into integer cents rather than trusting float equality).
  • The root element is dropped. The reader discards the document root when converting to an array, so element('Envelope') won't find anything — query the children underneath it. This trips up nearly everyone once.
  • Silent empty results on namespaces. A missing withNamespace() call doesn't error, it just returns zero results. If a query returns nothing, dump the raw body and check the namespace declarations before you touch anything else.
  • first() returns null quietly, sole() throws. Use sole() for required fields, first() for optional ones. The distinction is your cheapest validation.
  • The writer's terminal call hands back a document object, not a raw string. Cast it ((string)) or call ->toString() before passing it to Saloon's body() — otherwise you'll debug a request that looks like it has content but doesn't.
  • Debug by dumping the parsed structure, not the XML. When something is off, dd($reader->query('//thing')->get()) (or ray()) shows you exactly what the parser saw. Almost every "the parser is wrong" is actually "the payload is different than we assumed".
  • Pin the version and note the PHP floor. The 1.3.x line moved to PHP 8.3; 1.4.x added 8.4 support, 1.4.1 added 8.5. If you're stuck on an older PHP, pin below the bump. Pin a minor in composer.json and let Renovate/Dependabot surface the upgrades — releases so far have been small and PHP-compatibility-driven.
  • No XSLT, no complex XML-to-XML mapping, no document modelling. If the requirement grows into any of those, that's a signal to reach for a different tool rather than growing a helper class around this one.
  • Watch memory on big batches. Streams were fixed early (rewind before access), but "large" here means megabytes, not gigabytes. Spike first, decide second.
  • Escape hatch is clean. Because this is a thin layer over veewee/xml and the surface is small, keeping it behind the internal facade above means replacing it stays a one-day job — which is exactly the argument that gets it through review.
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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox