PHP XML Expat Parser
What Is the Expat Parser? In simple terms: The XML Parser extension reads an XML document from start to finish and notifies your code whenever it finds something: "an element starts here", "here is so
What Is the Expat Parser?
In simple terms: The XML Parser extension reads an XML document from start to finish and notifies your code whenever it finds something: "an element starts here", "here is some text", "the element ends here". Your code reacts to these events and keeps only the data it needs. Because the document is never loaded completely into memory, this approach works for files of any size.
Technically: PHP's XML Parser extension is based on the Expat library and implements an event-based ("push" or SAX-style) parser. You create a parser with xml_parser_create(), register handler callbacks for start tags, end tags, and character data, and feed the document — whole or in chunks — to xml_parse(). Since PHP 8.0, parsers are XMLParser objects.
Event-Based Parsing
For <name>Gel Pen</name>, the parser triggers:
start element handler → name="name", attributes=[]
character data handler → "Gel Pen"
end element handler → name="name"
Your code must remember where it is in the document (for example, "inside a <product>") using its own variables — the parser does not build a tree.
Case Folding: An Important Default
By default, the parser converts tag and attribute names to uppercase:
<?php
$parser = xml_parser_create();
xml_set_element_handler(
$parser,
function ($parser, string $name, array $attributes): void {
echo "$name [" . implode(', ', array_keys($attributes)) . "]\n";
},
fn() => null
);
xml_parse($parser, '<product sku="PEN-01"><name>Pen</name></product>', true);
Output
PRODUCT [SKU]
NAME []
XML is case-sensitive, so this historical default is confusing. Disable it with xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, false), as all following examples do.
Parsing the Catalog
<?php
$products = [];
$current = null;
$text = '';
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, false);
xml_set_element_handler(
$parser,
function ($parser, string $name, array $attributes) use (&$current, &$text): void {
if ($name === 'product') {
$current = [
'sku' => $attributes['sku'] ?? '',
'category' => $attributes['category'] ?? '',
];
}
$text = '';
},
function ($parser, string $name) use (&$products, &$current, &$text): void {
if ($current !== null && in_array($name, ['name', 'price', 'stock'], true)) {
$current[$name] = trim($text);
}
if ($name === 'product') {
$products[] = $current;
$current = null;
}
$text = '';
}
);
xml_set_character_data_handler($parser, function ($parser, string $data) use (&$text): void {
$text .= $data;
});
foreach (str_split($catalogXml, 64) as $chunk) {
if (!xml_parse($parser, $chunk, false)) {
exit('XML error: ' . xml_error_string(xml_get_error_code($parser)) . "\n");
}
}
xml_parse($parser, '', true); // signal the end of the document
foreach ($products as $product) {
echo "{$product['sku']}: {$product['name']} ({$product['category']}) — {$product['stock']} in stock\n";
}
echo "Total stock: " . array_sum(array_column($products, 'stock')) . "\n";
Output
PEN-01: Gel Pen (stationery) — 120 in stock
LAMP-01: Desk Lamp (electronics) — 6 in stock
MOUSE-01: Wireless Mouse (electronics) — 30 in stock
Total stock: 156
Step-by-Step Explanation
- State variables track the parsing context:
$currentholds the product being read,$textcollects character data. - The start handler begins a new product record when
<product>starts, taking its attributes. For every element, it resets$text. - The character data handler appends text. It can be called several times for one text node (for example, when a chunk boundary falls in the middle of "Wireless Mouse"), so the text is accumulated, not overwritten.
- The end handler stores the collected text in the current record for
name,price, andstock, and adds the finished product to$productswhen</product>ends. - The document is fed in 64-byte chunks, simulating reading a large file piece by piece. The final
xml_parse($parser, '', true)tells the parser that the document is complete, so it can report unclosed elements. - Closures are passed as handlers and capture the state variables by reference (
use (&$text)).
In a real import, you would read chunks with fread() from a file and process (for example, insert into the database) each product as soon as its end tag arrives, instead of collecting all products in an array.
Handling Errors
<?php
$parser = xml_parser_create();
if (!xml_parse($parser, "<order>\n<item></order>", true)) {
printf(
"XML error: %s at line %d\n",
xml_error_string(xml_get_error_code($parser)),
xml_get_current_line_number($parser)
);
}
Output
XML error: Mismatched tag at line 2
xml_parse() returns false when the document is not well-formed. xml_get_error_code(), xml_error_string(), and the position functions (xml_get_current_line_number(), xml_get_current_column_number(), xml_get_current_byte_index()) describe the problem. Data processed before the error has already been handled by your callbacks, so imports should run inside a transaction or validate the whole file before committing.
Useful Functions and Options
| Function / Option | Purpose |
|---|---|
xml_parser_create($encoding) | Create a parser (UTF-8 recommended) |
xml_parser_set_option($p, XML_OPTION_CASE_FOLDING, false) | Keep original tag-name case |
xml_parser_set_option($p, XML_OPTION_SKIP_WHITE, true) | Skip whitespace-only character data |
xml_set_element_handler($p, $start, $end) | Register start and end tag callbacks |
xml_set_character_data_handler($p, $handler) | Register the text callback |
xml_parse($p, $data, $isFinal) | Parse a chunk; true for the last chunk |
xml_parse_into_struct($p, $xml, $values, $index) | Parse a whole string into flat arrays (convenient for small documents) |
Notes for modern PHP:
- Since PHP 8.0,
xml_parser_free()has no effect; the parser is released automatically. - Since PHP 8.4,
xml_set_object()and passing handler method names as strings are deprecated; pass closures or callables such as$this->startElement(...)directly.
When to Use Expat
| Use Expat When | Prefer Something Else When |
|---|---|
| Maintaining existing code built on it | Writing new streaming code — XMLReader's pull model is usually easier to follow |
| Data arrives in chunks (for example, from a network stream) | The document is small — SimpleXML is far simpler |
| Only a few values are needed from a huge document | You need XPath or random access — use DOM or SimpleXML |
Common Mistakes
Forgetting to Disable Case Folding
Effect: Comparisons like $name === 'product' never match (the name is PRODUCT).
Fix: Set XML_OPTION_CASE_FOLDING to false.
Assuming Text Arrives in One Piece
Effect: Truncated values when text is split across calls.
Fix: Accumulate character data and use it in the end handler.
Not Signaling the Final Chunk
Effect: Errors at the end of the document (unclosed elements) go undetected.
Fix: Call xml_parse($parser, '', true) (or pass true with the last chunk).
Ignoring the Return Value of xml_parse()
Effect: Malformed documents are partially processed without notice.
Fix: Check the return value and report the error position.
Best Practices
- Disable case folding and use UTF-8 parsers.
- Keep parsing state in a small set of variables or a dedicated class.
- Accumulate text and process records in end-element handlers.
- Feed large files in chunks and process records immediately.
- Consider XMLReader for new streaming code, and SimpleXML or DOM for small documents.
Official reference: https://www.php.net/manual/en/book.xml.php