goetas/xmldom
A lightweight XML DOM implementation for PHP, offering an object-oriented API to build, parse, traverse, and manipulate XML documents. It provides a simple, portable option for projects needing DOM-style XML handling without heavy dependencies.
Install
composer require goetas/xmldom
It requires ext-dom (bundled with most PHP builds; on Debian/Ubuntu it's php-xml). Before you commit to it, open its composer.json — this package is archived and pinned to an older PHP range, so on PHP 8.x you may need --ignore-platform-reqs, or better, fork it and relax the constraint.
Where to look first
The repo is small and undocumented. Spend ten minutes in this order:
src/ — almost always just Document.php and Element.php. That is the entire surface area.tests/ — archived packages usually have more test code than docs, and the tests are the only executable spec of what the wrapper actually returns.composer.json — PHP constraint, autoload namespace (Goetas\Xml\Dom), and whether it depends on anything else.The one question that determines everything
Check whether Goetas\Xml\Dom\Document registers the extended node classes via DOMDocument::registerNodeClass(), or whether it overrides createElement*() to return its own types manually. This matters enormously: with registerNodeClass() the whole tree you build and load is typed, so getElementsByTagName(), childNodes, and cloneNode() all hand you back the wrapper classes. With manual overrides, typing silently degrades to native \DOMElement at every boundary the author didn't patch. One var_dump(get_class($doc->createElementNS('urn:x', 'x:a'))) answers it.
First use case: namespaced XML generation
The package earns its keep when every element and attribute carries an xmlns/prefix — Atom feeds, SOAP envelopes, XSD-derived invoices. Native DOM makes this verbose; SimpleXML makes it lossy (whitespace, attribute order, mixed content). The wrapper is meant to give you a chainable, typed builder:
use Goetas\Xml\Dom\Document;
$doc = new Document('1.0', 'UTF-8');
$doc->formatOutput = true;
$feed = $doc->createElementNS('http://www.w3.org/2005/Atom', 'feed');
$doc->appendChild($feed);
$title = $doc->createElementNS('http://www.w3.org/2005/Atom', 'title');
$title->appendChild($doc->createTextNode('Hello'));
$feed->appendChild($title);
echo $doc->saveXML();
If that snippet doesn't return Goetas\Xml\Dom\Element objects you can call helper methods on, the package is offering you very little over plain DOMDocument.
Namespace-first construction
Never build a namespaced tree with createElement() + setAttribute('xmlns', ...). The DOM treats prefixes as cosmetic labels and the namespace URI as identity; declare the URI at creation and let createElementNS($uri, 'prefix:local') do the work. The same discipline applies to attributes — setAttributeNS() for anything in a namespace (including xsi:type, xml:lang, xmlns:xsi), plain setAttribute() only for unqualified ones.
Centralize your namespace URIs
Because prefixes are arbitrary, hard-coding 'soap:' literals scatters bugs. Keep a constant map per document type and a small helper that calls the right createElementNS. With an archived dependency this is doubly important — if you later swap the wrapper out for a fork or plain DOM, only the helper changes.
const NS_ATOM = 'http://www.w3.org/2005/Atom';
const NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance';
$el = $doc->createElementNS(NS_ATOM, 'atom:entry');
$el->setAttributeNS(NS_XSI, 'xsi:type', 'atom:EntryType');
Load → mutate → save
The wrapper is most useful for building, but it works fine for editing too. Load with explicit options and always check the return value — loadXML() returns false rather than throwing.
$doc = new Document();
$doc->preserveWhiteSpace = false;
if (! $doc->loadXML($xml, LIBXML_NONET)) {
throw new \RuntimeException('Malformed XML');
}
Then walk with getElementsByTagNameNS($uri, $local) and mutate. Save with saveXML(); save to file with save().
Interop with the goetas ecosystem
This library sits in the same family as goetas-webservices/xsd2php and the SOAP client stack — that's its intended niche: generating typed documents from XSD metadata. If you're pulling it in standalone, you're using it outside its design intent, which is fine but means you own the edge cases.
Serialization as the contract
Treat saveXML() output as the thing you assert on, not the object graph. For anything that might be byte-compared downstream (signatures, hashes, golden-file tests), apply C14N() on the node instead of string-comparing pretty-printed output — canonicalization normalizes namespace declarations, attribute order, and whitespace.
$this->assertSame($expectedC14n, $node->C14N());
Subclass for your domain
Since the wrapper's value is that extended types propagate, subclass Goetas\Xml\Dom\Document once for your application (InvoiceDocument extends Document) and hang your builder methods there. That's the documented-by-implication extension point, and it keeps call sites clean. If propagation turns out to be broken in the archived code, you can repair it yourself with registerNodeClass() in the constructor.
It's archived and has essentially no users. One star, zero dependents means no Stack Overflow answers, no issue history to search, and no upstream fixes for whatever PHP 8.x incompatibility you hit. Pin the exact version in composer.json, and if you ship it, fork it into your VCS and treat that fork as the real dependency. Do not expect this to survive a PHP major version bump on its own.
Namespace URI vs. prefix is the #1 source of confusion. Two elements with different prefixes but the same URI are the same namespace; two with the same prefix but different URIs are not. getElementsByTagName('atom:title') will not find namespaced elements — you need getElementsByTagNameNS(). When something "isn't there," dump $doc->saveXML() and look at the actual declarations rather than trusting your mental model of the tree.
formatOutput = true changes your data. It injects whitespace text nodes. That will break signature validation, byte-comparison tests, and anything expecting exact mixed content. Turn it off for production serialization and on only for debugging.
XXE is off by default in modern libxml, but don't rely on defaults. Never pass LIBXML_NOENT to untrusted input, always include LIBXML_NONET, and avoid LIBXML_DTDLOAD. If you're upgrading an old codebase that used this package, audit every load()/loadXML() call — the defaults it was written against were looser.
saveXML($node) on a child element includes inherited namespace declarations from ancestors, which surprises people diffing subtrees. That's correct XML behavior, not a bug, but it's why golden-file tests on fragments fail.
Typed tree propagation breaks at the edges. Even with registerNodeClass(), watch out for: cloneNode() (verify it returns your type), DOMXPath::query() results (a DOMNodeList of native nodes in some PHP versions), and importNode() across documents. When you need a wrapper method on one of these, re-wrap explicitly rather than assuming.
Debugging toolkit: $doc->saveXML() for the serialized truth, $node->nodeType and $node->localName/namespaceURI for identity, libxml_get_errors() after any load for the real parse error (the false return tells you nothing). Clear the error buffer with libxml_clear_errors() between loads or you'll read stale messages.
Consider whether you need it at all. If you're not subclassing DOM and not doing heavy namespaced generation, native DOMDocument plus a dozen lines of helper methods gives you the same ergonomics with a maintained dependency. The honest threshold for adopting this archived package: you're in the goetas XSD/SOAP toolchain already, or you've read the source and found a specific behavior worth the maintenance burden.
How can I help you explore Laravel packages today?