Parse real-world HTML and untrusted XML in PHP without regex or network surprises
Choose the parser from the grammar and document size, isolate fetching, disable network/entity expansion, query with XPath, handle encoding and errors, and sanitize output separately.
A product importer extracts prices with a regular expression. One supplier adds an attribute before class, another wraps the price in <span>, and the importer silently records zero. Markup has nesting, entities, encodings and error-recovery rules. Use a parser that implements the relevant grammar, then make fetching, querying and output safety explicit.
Decide whether the input is HTML or XML
| Input | Parser direction |
|---|---|
| Modern web page, possibly malformed | PHP 8.4 Dom\HTMLDocument or an HTML5 parser |
| Strict XML API document | Dom\XMLDocument/DOMDocument::loadXML |
| Large XML feed | XMLReader streaming cursor |
| Small trusted XML with simple traversal | SimpleXML can be concise |
| Arbitrary substrings in known plain text | Regular expressions may be appropriate after parsing |
HTML parsing includes error recovery and HTML element rules. XML is case-sensitive and must be well formed. Feeding arbitrary HTML to an XML parser or silently repairing malformed XML changes the contract.
Use the HTML Living Standard parser in PHP 8.4+
use Dom\HTMLDocument;
$document = HTMLDocument::createFromString(
$html,
LIBXML_NONET,
'UTF-8'
);
$xpath = new DOMXPath($document);
$nodes = $xpath->query(
'//article[contains(concat(" ", normalize-space(@class), " "), " product ")]'
);
PHP 8.4 introduced Dom\HTMLDocument, whose parser follows the HTML Living Standard. The older DOMDocument::loadHTML() uses an HTML 4 parser and can produce a different tree for modern markup. Pin the runtime and parser choice in tests.
Support older runtimes consciously
$previous = libxml_use_internal_errors(true);
try {
$document = new DOMDocument('1.0', 'UTF-8');
$ok = $document->loadHTML(
$html,
LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING
);
if (!$ok) {
throw new RuntimeException('HTML could not be parsed');
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
Do not use @ to suppress warnings globally. Capture libxml errors, attach a bounded sanitized summary to diagnostics, clear the buffer and restore the previous setting. Error messages can contain input fragments, so avoid logging customer content blindly.
Fetch first, parse second
$response = $httpClient->request('GET', $approvedUrl, [
'timeout' => 10,
'max_redirects' => 3,
'headers' => ['Accept' => 'text/html,application/xhtml+xml'],
]);
$contentType = $response->getHeaderLine('Content-Type');
$html = (string) $response->getBody();
Passing user-controlled URLs directly to parsing functions can create server-side request forgery and uncontrolled redirects. Resolve and validate the scheme, host, port and every redirect; block loopback, link-local, private and metadata ranges as policy requires. Apply byte and time limits before parsing.
Use XPath instead of markup-shaped string patterns
function textOf(DOMXPath $xpath, DOMNode $context, string $query): ?string
{
$node = $xpath->query($query, $context)?->item(0);
return $node ? trim($node->textContent) : null;
}
foreach ($nodes as $product) {
$name = textOf($xpath, $product, './/h2');
$price = textOf(
$xpath,
$product,
'.//*[@data-role="price"]'
);
}
Relative XPath begins with . so each query stays within the current product. Prefer stable semantic attributes supplied for integration over fragile position-based selectors. Treat a missing node as a schema event, not an empty price.
Match class tokens correctly
//article[
contains(
concat(" ", normalize-space(@class), " "),
" product "
)
]
contains(@class, "product") also matches productivity. The normalized-space idiom matches a complete whitespace-separated class token in XPath 1.0.
Handle namespaces in XML
$document = new DOMDocument();
$document->loadXML($xml, LIBXML_NONET);
$xpath = new DOMXPath($document);
$xpath->registerNamespace('inv', 'urn:example:invoice:v2');
$totals = $xpath->query('//inv:invoice/inv:total');
A default XML namespace still needs a prefix in XPath queries. Bind it to a local prefix explicitly rather than stripping namespaces, which can merge distinct vocabularies.
Disable network and dangerous entity behavior
$options = LIBXML_NONET;
if (defined('LIBXML_NO_XXE')) {
$options |= LIBXML_NO_XXE; // PHP 8.4+ with supported libxml
}
$document = new DOMDocument();
if (!$document->loadXML($xml, $options)) {
throw new InvalidArgumentException('Invalid XML');
}
LIBXML_NONET disables network access while parsing. PHP’s manual warns that LIBXML_NOENT substitutes entities and can enable XXE attacks; do not add it to “fix” entity text. PHP 8.4 exposes LIBXML_NO_XXE with sufficiently new libxml for explicit protection when entity substitution is performed.
Apply size and complexity limits before building a tree
if (strlen($xml) > 8 * 1024 * 1024) {
throw new LengthException('Document exceeds 8 MiB limit');
}
if (substr_count($xml, '<') > 500000) {
throw new LengthException('Document complexity limit exceeded');
}
Crude prechecks are only a first gate. DOM keeps the document tree in memory and can use several times the input size. Enforce HTTP body limits, worker memory limits, parser depth/expansion controls where available and job timeouts. Never enable LIBXML_PARSEHUGE for untrusted input casually.
Stream large XML with XMLReader
$reader = XMLReader::fromString(
$xml,
null,
LIBXML_NONET | (defined('LIBXML_NO_XXE') ? LIBXML_NO_XXE : 0)
);
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT
&& $reader->localName === 'invoice') {
$invoice = $reader->expand();
processInvoice($invoice);
}
}
$reader->close();
XMLReader is a forward-only pull parser and avoids retaining the entire feed. Expand only the subtree needed for one record. On PHP 8.4, XMLReader::fromStream() can consume an already-open stream directly.
Normalize encoding from authoritative metadata
HTTP content type, BOM, XML declaration and HTML metadata can disagree. Preserve the raw bytes for a bounded diagnostic sample, follow the selected parser’s encoding algorithm, and convert application output to UTF-8 deliberately. Avoid prepending fake XML declarations as a universal encoding workaround.
if (!mb_check_encoding($value, 'UTF-8')) {
throw new UnexpectedValueException('Parser returned invalid UTF-8');
}
Parsing is not sanitization
$title = $node->textContent;
echo htmlspecialchars(
$title,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
A parser creates a tree; it does not make untrusted markup safe to render. If you need a text value, output-encode it for HTML. If a limited HTML subset is allowed, use a maintained allowlist sanitizer that removes dangerous elements, event attributes, URL schemes, CSS and namespace tricks.
Resolve extracted links safely
$href = $link->getAttribute('href');
// Resolve against the final response URL using an RFC-compliant URI library.
// Validate the resolved scheme and host again before fetching.
Relative links use the document base, potentially changed by redirects or a <base> element. Never concatenate strings or automatically fetch every extracted URL. Canonicalize, authorize and rate-limit each follow-up request.
Detect upstream schema drift
$result = [
'products_seen' => $nodes->length,
'products_valid' => count($products),
'missing_name' => $missingName,
'missing_price' => $missingPrice,
];
Alert on ratios and sudden changes, not only parser exceptions. A syntactically valid redesigned page can yield an empty dataset. Retain fixture snapshots under permission and copyright constraints, and test multiple real structural variants.
Build a parser contract test suite
- Valid document and expected output.
- Malformed HTML with browser-style recovery.
- Namespaces, entities and mixed encodings.
- Missing and duplicated fields.
- Oversized, deeply nested and expansion-oriented inputs.
- External entity and network-fetch attempts.
- Unsafe markup that must not survive rendering.
Choose the parser from the grammar and data size. Keep network access outside parsing, keep entity expansion off, and treat extraction, validation and sanitization as separate stages.
Sources: the Stack Overflow parser survey (question by RobertPitt; accepted answer by Gordon, CC BY-SA), and PHP’s official documentation for Dom\HTMLDocument, XMLReader, libxml security flags, and error handling.
Primary source: Review the official reference ↗