XML in PHP: DOM, SimpleXML, XMLReader/Writer, XPath, and Security

Choose the right XML API for the job — DOM or SimpleXML for small documents held fully in memory, XMLReader/XMLWriter for streaming large ones — query documents with XPath, validate against a schema, and parse untrusted XML securely by understanding and avoiding XXE (XML External Entity) vulnerabilities.

XML shows up constantly in integration work — SOAP APIs, RSS/Atom feeds, sitemap files, configuration formats, data exports from legacy enterprise systems, and generated reports. PHP ships with several distinct XML tools that solve different problems: SimpleXML for quick, convenient access to small documents; DOM for full read/write tree manipulation and XPath querying; XMLReader/XMLWriter for streaming documents too large to hold in memory; and a security dimension unique to XML — the XXE vulnerability — that any code parsing XML from an untrusted source needs to understand. This lesson tours all of it, ending on the parsing-securely question that every other section depends on getting right.

DOM vs. SimpleXML: Two Ways to Represent a Document

SimpleXML (simplexml_load_string() / simplexml_load_file()) turns an XML document into a tree of SimpleXMLElement objects where child elements are accessed as if they were object properties, and repeated sibling elements behave like an array — $catalog->book[0]->title reads naturally close to the document's own shape. It's the fastest way to pull a few values out of a small, well-known document, but it's read-oriented and awkward for anything involving namespaces, mixed content, or building a document up programmatically from scratch.

DOMDocument (and its node classes — DOMElement, DOMText, DOMAttr, and so on) represents the same document as a full, standards-based DOM tree, the same conceptual model browsers use for HTML. It's more verbose than SimpleXML for simple reads, but it's the API that supports XPath querying, full read/write tree manipulation (creating, moving, and removing nodes), schema validation, and namespace-aware traversal — anything beyond 'read a few values out' generally ends up on DOM rather than SimpleXML.

Both APIs load the entire document into memory as a tree before you can query anything, which is fine for typical config files and small feeds, and a real problem for documents in the tens or hundreds of megabytes — that's exactly the gap XMLReader and XMLWriter exist to fill.

• SimpleXML: convenient, property-like access — best for quick reads of small, well-known documents. • DOMDocument: full tree model, supports XPath, read/write manipulation, namespaces, and schema validation. • Both hold the entire parsed document in memory as a tree — unsuitable for very large files. • You can convert between them: simplexml_import_dom() and dom_import_simplexml() bridge the two APIs on the same underlying document.

XMLReader and XMLWriter: Streaming Without Loading Everything

XMLReader is a pull parser: instead of building a tree, it exposes a cursor you advance forward through the document with read(), checking nodeType and localName at each step to react to elements as they're encountered, one at a time, without ever holding the whole document in memory. For a large XML file made of many repeated records (a product catalog with 500,000 <book> elements, say), the standard technique is to read forward until you reach each <book> start tag, pull just that one element out as a small XML fragment with readOuterXML(), optionally parse that small fragment with SimpleXML/DOM for convenient access, process it, and move on — memory use depends only on the size of one record, not the whole file.

XMLWriter is the mirror image for output: instead of building a DOM tree in memory and serializing it all at once at the end, you write elements incrementally as you go — startElement(), writeAttribute(), writeElement(), endElement() — with flush() available to push output as it's produced. Pointed at php://output, it can stream a very large generated XML document straight to the response without ever assembling the whole thing in PHP memory first, the same way you'd stream a large CSV export.

Both trade the tree model's convenience (random access, easy re-querying with XPath) for bounded memory use — the right choice specifically when a document's size makes DOM/SimpleXML impractical, not a universal replacement for them.

• XMLReader: a pull/streaming parser — advance a cursor through the document instead of building a tree. • readOuterXML() pulls the current element out as a small fragment, parseable on its own with SimpleXML/DOM. • XMLWriter: writes output incrementally, element by element, instead of serializing a full in-memory tree at once. • Both keep memory use proportional to one record/chunk at a time, not the whole document.

XPath Queries and a Brief Look at XSLT

XPath is a query language for selecting nodes out of an XML (or HTML) document by path and condition — //book[price > 40]/title selects every title element that's a child of a book element anywhere in the document, where that book's price exceeds 40. In PHP, DOMXPath wraps a loaded DOMDocument and runs XPath expressions against it via query() (returns a DOMNodeList of matching nodes) or evaluate() (can also return a scalar — a count, a string, a boolean — depending on the expression).

XSLT (Extensible Stylesheet Language Transformations) is a full templating language for transforming one XML document into another (or into HTML/text) using declarative match-and-template rules, executed in PHP through the separate XSLTProcessor class loaded with a compiled DOMDocument stylesheet. It's a deep topic in its own right and less commonly reached for in modern PHP applications than XPath queries or straightforward DOM manipulation, but it remains the standard tool when a document needs to be transformed according to rules defined declaratively in a stylesheet rather than imperatively in PHP code — common in legacy enterprise/SOAP integration pipelines.

• XPath: a path-and-condition query language for selecting nodes — DOMXPath::query() / evaluate() run it against a DOMDocument. • query() returns a DOMNodeList of matching nodes; evaluate() can also return a scalar result. • XSLT: a declarative document-to-document transformation language, run via XSLTProcessor with a stylesheet DOMDocument. • XSLT is most common in legacy enterprise/SOAP pipelines — modern code more often reaches for XPath + direct DOM manipulation.

Namespaces and Validation Against a Schema

XML namespaces (xmlns:c="https://example.com/catalog") let a document mix elements from different vocabularies without name collisions, and every DOM/XPath/SimpleXML operation on a namespaced document needs to account for them explicitly — a bare tag-name query silently matches nothing against elements that are actually in a namespace. DOMXPath::registerNamespace($prefix, $uri) registers a prefix you can then use inside your own XPath expressions (//c:book), regardless of what prefix the original document itself happened to use.

Validating that an XML document actually conforms to an expected structure is a common integration requirement, and DOM supports it directly against either an XSD schema (DOMDocument::schemaValidate($xsdPath)) or a DTD (DOMDocument::validate() against a DOCTYPE already declared in the document, or schemaValidateSource()/DTD-based validation variants). A failed validation doesn't throw by default — it returns false, and the specific error details are retrieved separately via libxml_get_errors() after enabling libxml_use_internal_errors(true), which suppresses PHP's own warnings in favor of structured error objects you handle yourself.

• Namespaced elements need namespace-aware queries — DOMXPath::registerNamespace() maps your own prefix to a URI for use in XPath expressions. • DOMDocument::schemaValidate($xsdPath) validates against an XSD schema; DTD-based validation is also supported. • Validation failure returns false rather than throwing — check libxml_get_errors() after enabling libxml_use_internal_errors(true). • Always libxml_clear_errors() after handling them, so stale errors don't leak into the next parse.

XXE Vulnerabilities and Secure Parsing

XML supports a DOCTYPE declaration that can define custom entities, including external entities that reference a URI to be fetched and substituted directly into the document — <!ENTITY xxe SYSTEM "file:///etc/passwd"> followed by &xxe; somewhere in the document body would, if the parser resolves it, splice the contents of that local file straight into the parsed data. Pointed at an internal URL instead of a local file, the same technique becomes server-side request forgery (SSRF) against internal services the attacker couldn't otherwise reach. This class of vulnerability — XML External Entity (XXE) injection — is one of the most consequential mistakes possible when parsing XML from an untrusted source (a file upload, an API request body, a webhook payload).

The good news: since libxml2 version 2.9 (bundled with PHP since roughly PHP 5.4/5.5, and unconditionally the case on every currently supported PHP version including 8.2), external entity loading is disabled by default. The libxml_disable_entity_loader() function that older tutorials tell you to call is deprecated as of PHP 8.0 and is effectively a no-op now — it exists for backward compatibility, but there's nothing left for it to meaningfully disable. Modern PHP's default XML-parsing behavior is already XXE-safe.

The vulnerability today comes almost entirely from code that actively re-opens the hole: passing LIBXML_NOENT (substitute entities) together with LIBXML_DTDLOAD (load the external DTD) to loadXML()/load()/simplexml_load_string() explicitly re-enables external entity resolution for that parse. There's rarely a good reason to pass both of those flags together on untrusted input — if a document genuinely needs internal entity substitution, LIBXML_NOENT alone (without LIBXML_DTDLOAD) only expands entities already defined *inside* the document itself, not ones fetched from an external URI.

• XXE: a DOCTYPE-declared external entity referencing a URI (a local file, or an internal network address) that gets fetched and substituted into the parsed document. • Consequences: local file disclosure, or SSRF against internal services, depending on what the entity's URI points at. • Since libxml2 2.9 (all currently supported PHP versions), external entity loading is disabled by default — libxml_disable_entity_loader() is deprecated and effectively a no-op now. • The real risk today: explicitly passing LIBXML_NOENT together with LIBXML_DTDLOAD on untrusted input, which re-enables external entity resolution. • Never fetch/parse untrusted XML with flags that re-enable DTD loading and entity substitution together.

Parsing Untrusted XML Securely

No LIBXML_NOENT / LIBXML_DTDLOAD flags are passed -- external entity resolution stays off, which is the safe default on every supported PHP version. LIBXML_NONET additionally blocks any network fetch a parse might otherwise attempt, an extra layer of SSRF protection. libxml_use_internal_errors(true) converts parse warnings into structured errors you retrieve explicitly, instead of raw PHP warnings.

<?php
declare(strict_types=1);

function parseUntrustedXml(string $xmlSource): DOMDocument
{
    libxml_use_internal_errors(true);

    $dom = new DOMDocument();
    $ok = $dom->loadXML($xmlSource, LIBXML_NONET);

    if (!$ok) {
        $errors = libxml_get_errors();
        libxml_clear_errors();
        throw new RuntimeException('Invalid XML: ' . ($errors[0]->message ?? 'unknown error'));
    }

    return $dom;
}

Code Examples

SimpleXML's property-and-array-style access ($simple->book[0]->title) reads close to the document's own shape and is the quickest path to a couple of values; DOM's getElementsByTagName() plus textContent reaches the same value through the more verbose, more capable tree API that XPath and full manipulation both build on. Expected result: PHP in Depth 102 PHP in Depth

The reader's cursor advances node by node through the file; only when it lands on a book element's start tag does readOuterXML() pull out that single record as a small XML fragment, which is then parsed on its own with SimpleXML -- memory use stays bounded by the size of one book element, not the size of the whole catalog file. This pattern -- streaming cursor + per-record small-fragment parse -- is the standard way to process XML files too large to load as a single DOM/SimpleXML tree.

$reader->localName === 'book' The document uses the prefix c, but XPath queries only care about the registered prefix-to-URI mapping, not the document's own prefix spelling -- registering 'cat' for the same URI lets the query use //cat:book regardless. The condition cat:price > 40 filters nodes directly in the XPath expression itself. Expected result: SQL Internals

$xpath->registerNamespace('cat', 'https://example.com/catalog');

Given input containing <!DOCTYPE x [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><root>&xxe;</root>, parseVulnerable() would splice the contents of /etc/passwd straight into $dom->textContent, because LIBXML_DTDLOAD tells the parser to load the (attacker-controlled) external DTD and LIBXML_NOENT tells it to substitute the entity it defines. parseSecure() never passes either flag, so the entity is left unresolved rather than fetched.

$dom->loadXML($untrustedXml, LIBXML_NOENT | LIBXML_DTDLOAD); // do not do this on untrusted input

Common mistake: It's not enough to just 'use DOMDocument' safely -- the specific combination of LIBXML_NOENT with LIBXML_DTDLOAD is what reopens a hole that's closed by default; audit any code passing those flags together against untrusted XML.

<?php
declare(strict_types=1);

$xml = <<<XML
<catalog>
    <book id="101"><title>PHP in Depth</title><price>39.99</price></book>
    <book id="102"><title>SQL Internals</title><price>44.50</price></book>
</catalog>
XML;

$simple = simplexml_load_string($xml);
echo $simple->book[0]->title . PHP_EOL;
echo (string) $simple->book[1]['id'] . PHP_EOL;

$dom = new DOMDocument();
$dom->loadXML($xml);
$firstTitle = $dom->getElementsByTagName('title')->item(0)->textContent;
echo $firstTitle . PHP_EOL;
<?php
declare(strict_types=1);

$reader = new XMLReader();
$reader->open('huge-catalog.xml');

$processed = 0;
while ($reader->read()) {
    if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'book') {
        $bookXml = $reader->readOuterXML();
        $book = new SimpleXMLElement($bookXml);
        processBook((string) $book->title, (float) $book->price);
        $processed++;
    }
}
$reader->close();

echo "Processed {$processed} books without holding the file in memory" . PHP_EOL;
<?php
declare(strict_types=1);

$xmlWithNamespace = <<<XML
<c:catalog xmlns:c="https://example.com/catalog">
    <c:book><c:title>PHP in Depth</c:title><c:price>39.99</c:price></c:book>
    <c:book><c:title>SQL Internals</c:title><c:price>44.50</c:price></c:book>
</c:catalog>
XML;

$dom = new DOMDocument();
$dom->loadXML($xmlWithNamespace);

$xpath = new DOMXPath($dom);
$xpath->registerNamespace('cat', 'https://example.com/catalog');

foreach ($xpath->query('//cat:book[cat:price > 40]/cat:title') as $node) {
    echo $node->textContent . PHP_EOL;
}
<?php
declare(strict_types=1);

// VULNERABLE: LIBXML_NOENT + LIBXML_DTDLOAD together re-enable external
// entity resolution -- an attacker-supplied DOCTYPE can now read local
// files or reach internal network addresses through this parse.
function parseVulnerable(string $untrustedXml): string
{
    $dom = new DOMDocument();
    $dom->loadXML($untrustedXml, LIBXML_NOENT | LIBXML_DTDLOAD);
    return $dom->textContent;
}

// SECURE: no flags that re-enable DTD loading or external entity
// substitution -- relies on the safe-by-default behavior every
// currently supported PHP version already has.
function parseSecure(string $untrustedXml): string
{
    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $ok = $dom->loadXML($untrustedXml, LIBXML_NONET);
    libxml_clear_errors();

    if (!$ok) {
        throw new RuntimeException('Invalid XML.');
    }

    return $dom->textContent;
}

Tree Parsing vs. Streaming Parsing

Use the tree APIs when the document comfortably fits in memory; switch to XMLReader/XMLWriter when it doesn't.

DOM / SimpleXML (tree)              XMLReader (pull/streaming)
-----------------------              ---------------------------
Load entire file --> build            Advance cursor -> node -> node -> ...
full tree in memory                   Only the CURRENT node is materialized
        |                                       |
Query freely, anywhere,               readOuterXML() pulls just ONE
any number of times (XPath)           element out as a small fragment
        |                                       |
Memory  ~= size of WHOLE file         Memory ~= size of ONE record

Important Rules

Use SimpleXML for quick reads of small, well-known documents; use DOM whenever you need XPath, namespaces, or write access.

Switch to XMLReader/XMLWriter once a document is too large to comfortably hold as a full in-memory tree.

Register namespace prefixes explicitly with DOMXPath::registerNamespace() before querying a namespaced document.

Validate untrusted XML structurally with schemaValidate() before trusting its shape, and check libxml_get_errors() rather than relying on a thrown exception.

Never pass LIBXML_NOENT together with LIBXML_DTDLOAD when parsing XML from an untrusted source.

Prefer LIBXML_NONET when parsing untrusted XML, to block any network fetch the parser might otherwise attempt.

Always libxml_clear_errors() after reading libxml_get_errors(), so a later parse doesn't see stale errors from an earlier one.

Where This Shows Up

Payment gateways, shipping carriers, and many B2B/EDI integrations still exchange XML rather than JSON, and any endpoint that accepts an XML request body or webhook payload from outside your own systems is exactly the kind of untrusted-input path where XXE matters most. Sitemap generation and RSS/Atom feed export for large catalogs are common real-world cases for XMLWriter's streaming output, and XMLReader shows up in ETL jobs importing large product or inventory feeds from suppliers.

$dom->loadXML($userSuppliedXml, LIBXML_NOENT | LIBXML_DTDLOAD); // parses an attacker-controlled webhook payload

$dom->loadXML($userSuppliedXml, LIBXML_NONET); // relies on the safe-by-default entity handling; no flags re-enable it

LIBXML_DTDLOAD instructs the parser to fetch and load the document's (possibly attacker-controlled) external DTD, and LIBXML_NOENT tells it to substitute the entities that DTD defines -- together they reopen XXE on input a parser would otherwise safely refuse to resolve. There's essentially never a legitimate reason to pass both on data you didn't generate yourself.

$dom = new DOMDocument(); $dom->loadXML(file_get_contents('huge-export.xml')); // 800MB file, loaded whole into memory as a string, then as a tree

$reader = new XMLReader(); $reader->open('huge-export.xml'); while ($reader->read()) { /* process one element at a time via readOuterXML() */ } file_get_contents() alone already loads the whole file into a PHP string, and DOM then builds a second, larger in-memory tree representation on top of that -- doubling memory pressure on a file that's already too large to comfortably hold once. XMLReader never materializes more than the current node at a time.

When should you reach for XMLReader/XMLWriter instead of DOMDocument or SimpleXML? A. Whenever namespaces are involved B. When the XML document is too large to comfortably hold as a full in-memory tree, and you need to stream through it instead C. Never — XMLReader is deprecated in favor of DOM D. Only when writing unit tests

B. When the XML document is too large to comfortably hold as a full in-memory tree, and you need to stream through it instead — DOM and SimpleXML both build the entire document as an in-memory tree before you can query it. XMLReader/XMLWriter trade that convenience for bounded memory use, streaming through a document (or writing one) incrementally — the right tool specifically once document size makes the tree approach impractical.

What actually causes an XXE vulnerability in modern PHP, given that external entity loading is disabled by default since libxml2 2.9? A. Simply calling new DOMDocument() at all B. Explicitly passing flags like LIBXML_NOENT together with LIBXML_DTDLOAD when parsing untrusted XML, which re-enables external entity resolution C. Using SimpleXML instead of DOMDocument D. Forgetting to call libxml_disable_entity_loader(), which is required on every parse

B. Explicitly passing flags like LIBXML_NOENT together with LIBXML_DTDLOAD when parsing untrusted XML, which re-enables external entity resolution — External entity loading is off by default on every currently supported PHP version, and libxml_disable_entity_loader() is deprecated and effectively a no-op now. The real risk today is code that explicitly opts back into external entity resolution by combining flags like LIBXML_NOENT with LIBXML_DTDLOAD on untrusted input.

What does DOMXPath::registerNamespace() do, and why is it necessary for namespaced documents? A. It rewrites the document to remove all namespaces B. It maps a prefix of your own choosing to a namespace URI so you can reference that namespace in your own XPath expressions, independent of whatever prefix the source document used C. It validates the document against an XSD schema D. It is only needed when writing XML, never when querying it

B. It maps a prefix of your own choosing to a namespace URI so you can reference that namespace in your own XPath expressions, independent of whatever prefix the source document used — XPath matches elements by namespace URI, not by prefix spelling, but XPath expressions still need a prefix syntactically to reference a namespace. registerNamespace() lets you pick your own prefix for a given URI and use it in query()/evaluate(), regardless of what prefix the original document happens to use for that same namespace.

Write a PHP function extractProductTitles(string $largeXmlPath): array that returns an array of every <title> text value found inside <product> elements in a large XML file, without loading the entire file into memory at once.

function extractProductTitles(string $path): array { $reader = new XMLReader(); $reader->open($path); $titles = []; while ($reader->read()) { if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'product') { $product = new SimpleXMLElement($reader->readOuterXML()); $titles[] = (string) $product->title; } } $reader->close(); return $titles; }

Use XMLReader to advance to each <product> element, then either read its title child directly with the reader's own navigation, or pull the whole <product> out with readOuterXML() and query it with a small, separate SimpleXML/DOM parse.