sabre/xml
sabre/xml is a specialized XML reader and writer for PHP, offering a simple, namespace-aware API for parsing and generating XML. It supports PHP 7.4 and 8, with full type declarations in version 3.
sabre/xml is a standalone PHP library (no Laravel service provider, no config file), so setup is just Composer:
composer require sabre/xml
Check the version constraint that resolves: 4.1+ requires PHP 8.2, 4.0.x supports PHP 7.4–8.1, and 3.x is the older typed line. If you extend Reader, Writer, or Service, v3+ requires you to match its parameter and return type declarations.
Read the four short pages at sabre.io/xml in this order: Introduction → Installation → Reading XML → Writing XML. They are the authoritative reference; the repo README is essentially an index. The single class you should understand first is Sabre\Xml\Service, which is a thin facade over Reader and Writer:
use Sabre\Xml\Service;
$service = new Service();
// Read: returns whatever the root element's deserializer produced
$data = $service->parse($xmlString);
// Write: returns an XML string
$xml = $service->write('order', [
'id' => 1234,
'status' => 'shipped',
]);
Your first real use case will almost always be one of two things: parsing an XML response from a third party (shipping carrier, SOAP endpoint, bank/payment file) into an array or DTO, or generating a fixed XML document for an outbound integration. Start by hand-writing the smallest possible elementMap for the one document you actually need — don't try to model the whole schema up front. Service::parse() on a real fixture, dumped with dd(), tells you more in ten seconds than any amount of schema reading.
Declarative mapping via elementMap and classMap. The idiomatic pattern is to stop hand-walking nodes and instead declare, per element, "how do I turn this into a value?" Both Service and Reader expose elementMap (Clark-notation name → callable) and classMap (Clark-notation name → class implementing XmlDeserializable):
use Sabre\Xml\Service;
$service = new Service();
$service->elementMap = [
'{urn:example}order' => \App\Xml\Order::class, // classMap-style
'{urn:example}line' => \App\Xml\Line::class,
'{urn:example}note' => 'Sabre\Xml\Element\XmlFragment', // keep raw
];
DTOs that round-trip. Implement both interfaces on the same class and the same object can be read and written:
class Order implements XmlSerializable, XmlDeserializable
{
public int $id;
public array $lines = [];
public static function xmlDeserialize(Reader $reader): self
{
$self = new self();
// keyValue() flattens children into an array; attributes land in '@attributes'
$values = Deserializer\keyValue($reader);
$self->id = (int) ($values['id'] ?? 0);
return $self;
}
public function xmlSerialize(Writer $writer): void
{
$writer->write(['id' => $this->id, 'line' => $this->lines]);
}
}
Note that xmlDeserialize is static and receives the already-positioned Reader; you must consume the element (the helper functions do this for you) or you'll desynchronise the stream.
Use the bundled deserializers instead of writing loops. Sabre\Xml\Deserializer has keyValue() (children → assoc array, attributes → @attributes), elementList() (repeated children → list), firstValue() (first child's value), getAttribute() (read a specific attribute), and functionCaller() (delegate to a closure — handy for one-off nodes). These compose well: build your map out of these before reaching for custom code.
Namespace handling is Clark notation everywhere. Keys are {http://namespace-uri}localName, never prefix:localName. On read this is automatic. On write, prefixes are resolved through the static Writer::$namespaceMap, so set it once at boot:
\Sabre\Xml\Writer::$namespaceMap = [
'urn:example' => 'ex',
];
Unmapped namespaces get emitted as a default xmlns="..." declaration, which is correct but can surprise you if the consumer expects a prefix.
Pass unknown content through with XmlFragment. When a document has signed or vendor-specific subtrees you don't care about, map them to Sabre\Xml\Element\XmlFragment (or Base for a generic array) so the data isn't silently dropped and can be re-serialized unchanged. Use Sabre\Xml\Element\Cdata for text that must not be escaped.
In Laravel, register a configured Service as a singleton with your element maps bound at construction, and inject it into small action classes or jobs. Heavy XML work (batch invoice generation, multi-megabyte carrier responses) belongs in a queued job, not the request cycle. Writer::write() returns a string, so it drops straight into Storage::put(), an HTTP client body, or a response()->stream().
Stream large documents with Reader directly. Service::parse() takes a string; for a file on disk, construct a Reader and hand it an already-open XMLReader pointed at the path, wrapping the whole loop in a try/finally so you can close it.
Writer::$namespaceMap is global static state. Mutating it per-request (e.g. in a multi-tenant app) leaks into later writes in the same process, and tests will pass or fail depending on order. Configure it in a bootstrapper and treat it as immutable, or set it in setUp()/tearDown() of your tests.Service::parse() is not a validator. It happily accepts structurally-wrong-but-well-formed XML. For compliance/EDI/invoicing work, run DOMDocument::schemaValidate() (or a Schematron pass) after parsing; don't assume a clean round-trip means the payload was valid.LIBXML_NONET and never enable LIBXML_NOENT.trim() defensively.expect() throws Sabre\Xml\ParseException on a name mismatch or malformed XML. Wrap calls in try/catch and translate to a domain exception; the raw message ("expected {ns}foo, got {ns}bar") is good for logs but not for users.Writer or overriding xmlDeserialize without matching signatures produces fatal errors, not deprecation notices.Writer::write() inside xmlSerialize(). You're handed a live $writer; use writeElement(), writeAttributes(), startElement()/endElement(), writeText(). A nested static write starts a second document and corrupts output.XmlSerializable objects get serialized, but nothing inspects your array for attribute intent — call writeAttributes([...]) immediately after startElement(), before any content.elementMap entries fail silently. Unmapped elements fall back to the generic Base deserializer (array of children), so a typo in a namespace URI gives you an array instead of your DTO with no error. When debugging, dump $reader->getClark() inside a functionCaller() to confirm exactly which Clark name is being matched.readInnerXml() returns the raw markup of the current element — invaluable for inspecting a subtree that deserialized unexpectedly — but it's only valid while positioned on an element start.Service::getReader()/getWriter() let you pre-configure a reader or writer (indentation via setIndent(true), startDocument() for an XML declaration, openMemory()/outputMemory() for fragment building) before the facade uses it. Subclass Service when you need environment-specific maps rather than mutating the singleton.How can I help you explore Laravel packages today?