Reading XML in PHP With SimpleXML and DOMDocument Safely
Read XML in PHP 8.5 with SimpleXML, DOMXPath and XMLReader, avoid the boolean cast trap, handle namespaces and read parse errors with libxml_get_errors.

PHP has more ways to read XML than most languages, and the right one depends on what you need. SimpleXML is the friendly one: it presents a document as an object, and you read it with -> and []. DOMDocument with DOMXPath is the full DOM with XPath. XMLReader streams. All three sit on libxml2, so they share its parsing rules and its limits.
SimpleXML
$x = simplexml_load_file("../library.xml");
$usdCents = 0;
foreach ($x->book as $b) {
$note = isset($b->note) ? "'" . (string)$b->note . "'" : "<absent>";
$authors = array_map('strval', iterator_to_array($b->author, false));
echo $b['id'], " | ", $b->title, " | ", implode("; ", $authors), " | ", $b->price, " ", $b->price['currency'],
" | available=", var_export((string)$b['available'] === 'true', true), " | note=", $note, "\n";
if ((string)$b->price['currency'] === 'USD') $usdCents += (int)round((float)(string)$b->price * 100);
}
printf("USD total %d.%02d\n", intdiv($usdCents, 100), $usdCents % 100);b1 | Cache Patterns | Ana Lee; Bo Chen | 29.50 USD | available=true | note='Covers R&D'
b2 | SQL Tuning, 2nd Edition | Cy Ortiz | 44.00 USD | available=false | note=<absent>
b3 | Indexes "Explained" | Di Park | 250000 VND | available=true | note=''
USD total 73.50SimpleXML's model: children are properties ($b->title), repeated children are iterated (foreach ($x->book as $b)), and attributes are array keys ($b['id']). That makes simple reads very short. isset($b->note) is true for an element that exists, even an empty one, so the absent and empty notes are told apart, as the output shows. For the repeated author elements, the object is iterable, and iterator_to_array(..., false) gives a list.
The objects are not strings, which is the source of most SimpleXML bugs. $b->title is a SimpleXMLElement that prints as text when echoed. In comparisons, JSON encoding and array keys it can behave unexpectedly. Cast to string when you want text: (string)$b->title.
The boolean trap
var_dump((bool)$x->book[0]['available'], (bool)$x->book[1]['available']);bool(true)
bool(true)The first book's available is true and the second's is false, and both cast to true. A SimpleXML attribute is an object, and a non-empty object is truthy; the text inside it does not matter. The text "false" is also a truthy string in PHP, so even (bool)(string)$attr is true. The only correct way is to compare the text: (string)$b['available'] === 'true', as the loop above does, which gave available=false for book 2. The same applies to 0: (bool)(string)"0" is false in PHP, and "0" is how a feed writes false in many formats, which makes the string "false" and "0" behave differently under a cast. Write the comparison explicitly. Numbers have a related issue: (float)"29.50" is fine, and money in floats drifts, so convert to integer cents or use bcmath (a separate extension to install) or a decimal library.
Namespaces
$ns = simplexml_load_file("../lib_ns.xml");
echo "no ns: ", count($ns->book), " | with ns: ", count($ns->children("urn:example:library")->book), "\n";no ns: 0 | with ns: 2Plain property access finds elements in no namespace. For a namespaced document, call ->children($uri) (or ->children($prefix, true) with the prefix flag) to switch the view to that namespace, and read from there. Attributes in a namespace use ->attributes($uri). This is the same trap as in every library (how XML namespaces work): zero results and no error. Because SimpleXML offers no way to bind a default prefix once, code that reads a vocabulary uses a helper that applies children() at each level, or switches to DOM and XPath.
DOMDocument and XPath
For anything query-like, DOM with XPath is clearer:
$d = new DOMDocument(); $d->load("../library.xml");
$xp = new DOMXPath($d);
echo "xpath count USD books: ", $xp->evaluate("count(//book[price/@currency='USD'])"), "\n";xpath count USD books: 2DOMXPath::evaluate returns the typed result of the expression (a number for count, a node list for a path), and registerNamespace('s', $uri) binds a prefix for namespaced documents, which solves the namespace problem better than children(). SimpleXML objects also have ->xpath(), which returns an array of elements and takes the same default-namespace caveat.
Errors are silent unless you ask
By default, a malformed document makes simplexml_load_string print PHP warnings and return false, which is noisy in logs and easy to ignore. The better pattern collects the errors:
libxml_use_internal_errors(true);
$r = simplexml_load_string("<a><b></a>");
var_dump($r);
foreach (libxml_get_errors() as $e) { echo trim($e->message), " (line {$e->line})\n"; }
libxml_clear_errors();bool(false)
Opening and ending tag mismatch: b line 1 and a (line 1)
Premature end of data in tag a line 1 (line 1)With internal errors on, the function returns false quietly and libxml_get_errors() returns structured entries with a message, a line, a column and a level. Check for false explicitly, log the errors and return a clean failure. A very common production bug is $x = simplexml_load_string($xml); foreach ($x->item as ...) with $x === false, which produces a warning about reading a property of false and then proceeds with nothing. Also remember that the error state is global to the process: call libxml_clear_errors() after you read them, or the next parse inherits the old ones.
Streaming
XMLReader is a pull parser, and the route for big files:
$z = new XMLReader(); $z->open("../orders_nested.xml");
$n = 0;
while ($z->read()) { if ($z->nodeType === XMLReader::ELEMENT && $z->name === 'order') $n++; }XMLReader: 200000 orders in 0.44s, peak 2 MB
SimpleXML tree: 200000 orders in 0.78s, peak 2 MBThe reader counted 200,000 orders in 0.44 seconds, and loading the same file with SimpleXML took 0.78 seconds. The memory figures need a warning: memory_get_peak_usage(true) reported 2 MB for both, which is wrong in the sense that matters. libxml2 allocates its tree outside PHP's memory manager, so PHP's counter does not see it, and memory_limit does not limit it. Measure the process from outside (/usr/bin/time -v or the container's memory statistics) when you size a PHP worker for XML. For records, the idiom is to move the reader to an order element and hand $z->readOuterXml() or expand() to SimpleXML or DOM, one record at a time.
A habit that avoids most of these
Write one function per format that takes the loaded document and returns plain PHP arrays with the types you want: strings cast to string, flags compared to 'true', amounts converted to integer cents, notes null when absent. The rest of the program never sees a SimpleXMLElement. That keeps the casts and the namespace calls in one place with its own tests, and it makes the trap of the truthy object impossible to hit from the outside.
Security
PHP 8 on libxml2 2.9 or later does not load external entities by default, and the flag LIBXML_NOENT turns substitution back on; never pass it for untrusted input, and add LIBXML_NONET so nothing is fetched (safe XML handling in PHP and Node). Reject documents with a DOCTYPE by checking $doc->doctype after loading with DOM.
Related reading
For the language comparison see parsing XML in Java, C# and other typed languages. The extensions are documented in the PHP SimpleXML manual and the XMLReader manual. Test an XPath in the XPath tester.
When SimpleXML is the wrong reader in PHP
You need precise control or XPath over namespaces. Use DOM with DOMXPath and registerNamespace.
The file is big. Use XMLReader, and watch real process memory.
The document is mixed content. SimpleXML flattens inline markup oddly. DOM keeps the node structure.
You are writing XML. Use XMLWriter or DOM; do not build strings (sanitizing data you put into an XML document).


