Leer XML en PHP con SimpleXML, XPath y DOM

Leer XML en PHP: aunque JSON lo ha desplazado en las API, el XML sigue en todas partes, de las facturas electrónicas a los feeds RSS. PHP trae dos formas de leerlo: SimpleXML, para recorrerlo rápido, y DOM, para todo lo demás.

Usaremos este catálogo, con atributos y un espacio de nombres para los precios:

<?xml version="1.0" encoding="UTF-8"?>
<catalogo xmlns:p="https://ejemplo.com/precios">
  <libro id="b1" idioma="es">
    <titulo>Cien años de soledad</titulo>
    <autor>Gabriel García Márquez</autor>
    <p:precio moneda="MXN">389.00</p:precio>
  </libro>
  <libro id="b2" idioma="es">
    <titulo>Rayuela</titulo>
    <autor>Julio Cortázar</autor>
    <p:precio moneda="MXN">299.50</p:precio>
  </libro>
</catalogo>

Lo sencillo: SimpleXML

<?php
declare(strict_types=1);

$ruta = sys_get_temp_dir() . '/catalogo.xml';
file_put_contents($ruta, <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<catalogo xmlns:p="https://ejemplo.com/precios">
  <libro id="b1" idioma="es">
    <titulo>Cien años de soledad</titulo>
    <autor>Gabriel García Márquez</autor>
    <p:precio moneda="MXN">389.00</p:precio>
  </libro>
  <libro id="b2" idioma="es">
    <titulo>Rayuela</titulo>
    <autor>Julio Cortázar</autor>
    <p:precio moneda="MXN">299.50</p:precio>
  </libro>
</catalogo>
XML);

$catalogo = simplexml_load_file($ruta, options: LIBXML_NONET);

foreach ($catalogo->libro as $libro) {
    $precio = $libro->children('https://ejemplo.com/precios')->precio;
    printf("%s, de %s (%s): %s %s\n",
        $libro->titulo, $libro->autor, $libro['id'], $precio, $precio->attributes()['moneda']);
}

echo count($catalogo->libro), " libros\n";
$total = 0.0;
foreach ($catalogo->libro as $libro) {
    $total += (float) $libro->children('https://ejemplo.com/precios')->precio;
}
echo "Total: ", number_format($total, 2), "\n";

Probar en el editor →

salidaCien años de soledad, de Gabriel García Márquez (b1): 389.00 MXN
Rayuela, de Julio Cortázar (b2): 299.50 MXN
2 libros
Total: 688.50

Con SimpleXML, cada elemento es una propiedad ($libro->titulo) y cada atributo, una clave de array ($libro['id']). Los elementos con espacio de nombres, como p:precio, se piden con children() y la URI del espacio, no con el prefijo. Lo que devuelve son objetos SimpleXMLElement: para hacer cuentas hay que convertirlos con (float) o (string).

Buscar con XPath

<?php
declare(strict_types=1);

$ruta = sys_get_temp_dir() . '/catalogo.xml';
file_put_contents($ruta, <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<catalogo xmlns:p="https://ejemplo.com/precios">
  <libro id="b1" idioma="es">
    <titulo>Cien años de soledad</titulo>
    <autor>Gabriel García Márquez</autor>
    <p:precio moneda="MXN">389.00</p:precio>
  </libro>
  <libro id="b2" idioma="es">
    <titulo>Rayuela</titulo>
    <autor>Julio Cortázar</autor>
    <p:precio moneda="MXN">299.50</p:precio>
  </libro>
</catalogo>
XML);

$catalogo = simplexml_load_file($ruta, options: LIBXML_NONET);
$catalogo->registerXPathNamespace('p', 'https://ejemplo.com/precios');

$baratos = $catalogo->xpath('//libro[p:precio < 300]/titulo');
echo "Menos de 300: ", implode(', ', array_map('strval', $baratos)), "\n";

[$autor] = $catalogo->xpath('//libro[@id="b1"]/autor');
echo "Autor de b1: $autor\n";

Probar en el editor →

salidaMenos de 300: Rayuela
Autor de b1: Gabriel García Márquez

Lo completo: DOMDocument

<?php
declare(strict_types=1);

$ruta = sys_get_temp_dir() . '/catalogo.xml';
file_put_contents($ruta, <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<catalogo xmlns:p="https://ejemplo.com/precios">
  <libro id="b1" idioma="es">
    <titulo>Cien años de soledad</titulo>
    <autor>Gabriel García Márquez</autor>
    <p:precio moneda="MXN">389.00</p:precio>
  </libro>
  <libro id="b2" idioma="es">
    <titulo>Rayuela</titulo>
    <autor>Julio Cortázar</autor>
    <p:precio moneda="MXN">299.50</p:precio>
  </libro>
</catalogo>
XML);

$doc = new DOMDocument();
$doc->load($ruta, LIBXML_NONET);

foreach ($doc->getElementsByTagName('titulo') as $titulo) {
    echo $titulo->textContent, "\n";
}

$nuevo = $doc->createElement('libro');
$nuevo->setAttribute('id', 'b3');
$nuevo->appendChild($doc->createElement('titulo', 'Pedro Páramo'));
$doc->documentElement->appendChild($nuevo);

echo $doc->getElementsByTagName('libro')->length, " libros tras añadir uno\n";

Probar en el editor →

salidaCien años de soledad
Rayuela
3 libros tras añadir uno

DOM es más largo de escribir, pero permite todo: recorrer, modificar, crear y guardar documentos ($doc->save() o saveXML()). Es la opción cuando además de leer tienes que cambiar el XML.

XML de fuera, con cuidado. XML permite declarar «entidades» que el analizador sustituye por el contenido de un archivo o de una URL. Desde libxml 2.9, que es la que usa cualquier PHP actual, no se cargan por omisión. Para mantenerlo así: no pases nunca LIBXML_NOENT ni LIBXML_DTDLOAD al leer XML que no controlas, y añade LIBXML_NONET, como en estos ejemplos, para que el analizador no haga peticiones a la red. Más en los fallos de seguridad típicos.

Cuando el XML está mal

<?php
declare(strict_types=1);

libxml_use_internal_errors(true);

$xml = simplexml_load_string('<catalogo><libro>Sin cerrar</catalogo>', options: LIBXML_NONET);
var_dump($xml);

foreach (libxml_get_errors() as $error) {
    printf("Línea %d: %s\n", $error->line, trim($error->message));
}
libxml_clear_errors();

Probar en el editor →

salidabool(false)
Línea 1: Opening and ending tag mismatch: libro line 1 and catalogo
Línea 1: Premature end of data in tag catalogo line 1

Por omisión, un XML mal formado llena la salida de avisos. Con libxml_use_internal_errors(true) se guardan en silencio, la función devuelve false y puedes recoger los errores con libxml_get_errors() para mostrarlos como quieras.

Resumen

ParaUsa
Leer y recorrersimplexml_load_file($ruta, options: LIBXML_NONET)
Atributos$elemento['nombre']
Espacios de nombres->children($uri) o registerXPathNamespace
Buscar->xpath('//libro[@id="b1"]')
Modificar o crearDOMDocument
Errores sin avisoslibxml_use_internal_errors(true)

Todo el código de esta página se ejecutó con PHP 8.5.12 en un contenedor limpio, sin acceso a la red, antes de publicarla; las salidas y los mensajes de error están copiados de esa ejecución.