Processing HTML with the DOM: Traversal, XPath, and Sanitization
Load and traverse real-world HTML with DOMDocument, query it with DOMXPath, serialize a modified DOM back to a string, and build an allowlist-based, DOM-driven HTML sanitizer instead of relying on fragile regex string manipulation.
HTML is XML's messier cousin — browsers happily render documents with unclosed tags, missing quotes, and structurally invalid nesting, none of which a strict XML parser would accept. PHP's DOMDocument includes a dedicated, forgiving HTML parser (loadHTML()/loadHTMLFile()) built specifically to cope with that, and exposes the same DOM tree, traversal, and XPath tools covered for XML in the previous lesson. This lesson focuses on the HTML-specific side of that: loading and traversing real, possibly malformed HTML, querying it with XPath, serializing changes back to a string, and — the highest-stakes use case — sanitizing user-supplied HTML safely, by walking the parsed DOM against an explicit allowlist rather than trying to pattern-match dangerous markup with regular expressions.
Loading HTML: DOMDocument, Elements, and Traversal
DOMDocument::loadHTML() (or loadHTMLFile() for a file/URL) parses an HTML string using libxml's dedicated HTML parser, which is deliberately lenient — it doesn't require well-formed XML syntax the way loadXML() does. The result is the same kind of DOM tree covered in the XML lesson: DOMElement nodes for tags, DOMText nodes for text content, each with childNodes, parentNode, nextSibling/previousSibling for walking the tree structurally, plus convenience methods like getElementsByTagName() for finding every element with a given tag name anywhere in the document.
The HTML parser is chatty about markup it considers invalid — a missing <html>/<body> wrapper, an unclosed tag, an unrecognized attribute — and by default reports every one of these as a PHP warning, one per issue, which is unusable noise on real-world HTML that's never perfectly valid. libxml_use_internal_errors(true) suppresses those warnings in favor of structured error objects you can inspect (or simply discard) via libxml_get_errors(), and is essentially mandatory any time you're parsing HTML you didn't generate yourself.
By default, loadHTML() wraps whatever you give it in an implied full document structure (<html><body>...</body></html>, plus a default DOCTYPE) even if you only passed a fragment. When you're parsing a fragment on purpose — a snippet of user-submitted HTML, not a full page — passing LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD stops the parser from adding that implied wrapper and DOCTYPE, which keeps the resulting tree matching what you actually gave it.
• DOMDocument::loadHTML() / loadHTMLFile() parse HTML with libxml's lenient HTML parser — not the strict XML parser. • Same DOM node classes and traversal properties as XML: DOMElement, DOMText, childNodes, parentNode, nextSibling. • libxml_use_internal_errors(true) is essentially mandatory before loadHTML() on real-world HTML — otherwise every parsing quirk is a noisy PHP warning. • LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD prevents loadHTML() from wrapping a fragment in an implied <html><body> and DOCTYPE.
DOMXPath for Querying HTML
The same DOMXPath class covered for XML works identically against an HTML DOM tree — query() with a path-and-condition expression is often far more precise than scanning getElementsByTagName() results by hand, especially for structural queries like 'every <a> that's a descendant of an element with class="sidebar"' or 'every heading whose text contains a specific word.' Attribute-based selection (//img[@src], //a[contains(@href, "http")]), text-content filtering, and positional selection ((//tr)[position() > 1] to skip a table's header row) all work the same way they do against XML.
One practical wrinkle: getElementsByTagName() on an HTML DOM matches tag names case-insensitively the way HTML itself is case-insensitive, but a raw XPath expression like //div is a literal, case-sensitive string match against the tag name as parsed — worth remembering on real-world HTML pulled from sources that don't consistently lowercase their tags.
• DOMXPath works identically against HTML and XML DOM trees — same query()/evaluate() API. • Structural, attribute-based, and positional XPath queries are usually more precise than manual getElementsByTagName() scanning. • getElementsByTagName() is case-insensitive for HTML tags; raw XPath tag-name matches are case-sensitive strings. • Attribute queries like //a[contains(@href, "http")] work the same as they do on XML documents.
Parsing Real-World, Possibly Malformed HTML
Real HTML from the wild — scraped pages, old CMS exports, hand-edited content — routinely breaks basic well-formedness: unclosed <p> or <li> tags, attributes without quotes, stray < characters that aren't part of a tag at all, mismatched nesting. libxml's HTML parser is built to recover from exactly this kind of thing the same way a browser does, using well-established HTML parsing recovery rules (closing an unclosed <p> when a new block element starts, for instance) rather than simply failing on the first defect.
That recovery is forgiving, but not magic — genuinely severe malformation (binary garbage, a document that isn't HTML at all) can still produce a mostly-empty or nonsensical tree rather than a clean error. Code that processes arbitrary external HTML should treat the parse result defensively: check that expected elements actually exist before assuming they do ($dom->getElementsByTagName('body')->item(0) can come back null), rather than assuming loadHTML()'s lack of a thrown exception means the result is structurally what you expected.
• libxml's HTML parser recovers from common real-world malformation using standard HTML parsing recovery rules, much like a browser. • Recovery isn't magic — severely broken input can still yield a mostly-empty or unexpected tree. • loadHTML() not throwing is not proof the parsed structure matches what you expected — check that expected nodes actually exist before using them. • libxml_get_errors() after a parse gives visibility into exactly what the parser had to work around.
Serializing the DOM Back to a String
After modifying a DOM tree — adding a class attribute, removing a node, changing text content — DOMDocument::saveHTML() serializes the tree (or, given a specific node argument, just that subtree) back into an HTML string. This is the standard round-trip: parse, modify the tree using normal DOM methods (setAttribute(), removeChild(), appendChild(), createElement()), then serialize the result back out.
saveHTML($node) called with a specific node serializes only that node and its descendants, which is exactly what a fragment-based workflow (like sanitizing a snippet, not a whole page) needs — call it once per top-level child of your fragment's wrapper element and concatenate, rather than serializing the whole document (which, without LIBXML_HTML_NOIMPLIED, would include the implied <html><body> wrapper you never wanted in the output).
• DOMDocument::saveHTML() serializes the whole document; DOMDocument::saveHTML($node) serializes just that node and its descendants. • The standard round-trip: loadHTML() -> modify with normal DOM methods -> saveHTML() (or saveHTML($node) per fragment). • For fragment workflows, serialize each top-level child individually rather than the whole document, to avoid an unwanted implied wrapper in the output.
Sanitizing User-Supplied HTML: The Allowlist Approach
Sanitizing HTML means taking markup you don't trust (a comment, a rich-text editor's output, a pasted snippet) and producing markup you can safely render, stripping out anything that could execute script or otherwise misbehave — <script> tags, inline event handler attributes (onclick, onerror), javascript: URIs in href/src, and any tag or attribute you simply don't want to allow at all. The only reliable way to do this is an allowlist, walked structurally against the parsed DOM: decide exactly which tags are allowed and exactly which attributes are allowed on each of those tags, then remove — or unwrap, keeping just the text/children — anything that isn't on the list.
Regex-based sanitization (matching and stripping <script>...</script> with a pattern, or using strip_tags() with an allowed-tags string) looks tempting because it's short, but HTML isn't a regular language — nested, obfuscated, or unusually cased/spaced markup routinely slips past patterns that look reasonable in isolation, and strip_tags() in particular does nothing at all about dangerous *attributes* on tags it allows (an allowed <a> tag can still carry a javascript: href or an onclick handler straight through strip_tags() untouched). A DOM-based sanitizer instead parses the input into a real tree first — so the browser and your sanitizer are working from the same structural understanding of the markup, closing the exact gap that lets crafted, obfuscated input fool a text-pattern-based approach — then walks every element, unwraps or removes anything not on the tag allowlist, and strips every attribute not explicitly allowed for that tag (plus validating the ones that remain, like requiring an href to actually start with http(s):// rather than javascript:).
This is also where LIBXML_HTML_NOIMPLIED and per-node saveHTML() from the previous two sections come together in practice: load the untrusted fragment without an implied wrapper, walk and clean the resulting tree, then serialize back out node by node — producing exactly the safe subset of the original markup, and nothing else.
• Sanitization must be allowlist-based: decide which tags and which attributes-per-tag are allowed, and strip/unwrap everything else. • Regex/strip_tags() approaches operate on text, not structure — HTML isn't a regular language, and obfuscated/nested markup routinely defeats them. • strip_tags() in particular does nothing about dangerous attributes (onclick, javascript: hrefs) on tags it otherwise allows through. • A DOM-based sanitizer parses first, so it shares the same structural understanding of the markup as a renderer would, then walks and cleans the real tree. • Validate the attribute values you do keep (e.g. require href to start with http(s)://) — allowing the attribute name isn't enough on its own.
Large HTML Documents and Performance Considerations
Unlike XML, PHP's HTML parsing has no true streaming/pull equivalent to XMLReader — loadHTML() always builds the complete DOM tree in memory, and there's no HTML analogue that lets you process a huge document node-by-node without holding the whole thing. For genuinely large HTML documents (a very long scraped archive page, a huge generated report), the practical options are processing the document in chunks at a level above the parser (splitting on a reliable boundary before parsing each chunk separately, when the structure allows it), converting well-structured HTML to XHTML first so XMLReader's true streaming becomes available, or reaching for a different tool entirely outside PHP's built-in DOM extension for the largest cases.
For the common case — sanitizing or querying HTML that's large but not enormous (a full page, not a multi-gigabyte archive) — loadHTML()'s full in-memory parse is perfectly fine; it's worth being deliberate about it specifically when a document's size starts to threaten the memory limit, rather than assuming HTML processing scales the same way XML's XMLReader-based streaming does.
• loadHTML() always builds a full in-memory tree — there's no built-in streaming/pull HTML parser the way XMLReader provides for XML. • For genuinely huge HTML documents: chunk before parsing, convert to XHTML to unlock XMLReader, or use a tool outside DOM entirely. • For typical page-sized HTML, the full in-memory parse is fine — this is a concern specifically once size approaches the memory limit.
Loading a Real-World HTML Fragment Defensively
The leading '<?xml encoding="utf-8" ?>' pseudo-declaration tells libxml's HTML parser to treat the input as UTF-8, avoiding mojibake on non-ASCII content -- loadHTML() otherwise guesses encoding from the markup itself. LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD keeps the parsed tree matching a bare fragment, with no implied <html><body> wrapper or default DOCTYPE added. libxml_use_internal_errors(true)/libxml_clear_errors() suppress and then discard the routine warnings real-world HTML almost always triggers.
<?php
declare(strict_types=1);
function loadHtmlFragment(string $html): DOMDocument
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML(
'<?xml encoding="utf-8" ?>' . $html,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
return $dom;
}Code Examples
Despite the unquoted href attribute and the malformed, unclosed first <a> tag, libxml's HTML parser recovers using the same kind of error-tolerant rules a browser applies, producing a usable tree that getElementsByTagName('a') can walk normally. Expected result: /pricing /docs
$dom->loadHTML($rawHtmlFromTheWeb, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
The XPath expression selects every h2 that's a descendant of any div carrying class="product", anywhere in the document -- a single structural query that would take considerably more code to reproduce with manual tree-walking and class-attribute checks.
setAttribute() modifies the live DOM node directly, and saveHTML($container) serializes just that node and its descendants back to a string -- the loadHTML -> modify -> saveHTML round trip that underlies both simple template patching and, at larger scale, a DOM-based sanitizer. Expected result: <div><p class="greeting">Hello</p></div> echo $dom->saveHTML($container). PHP_EOL;
sanitizeNaive() removes an obvious <script>...</script> and nothing else -- it never touches an onclick attribute on an allowed <a>, and a nested or unusually-formed <script> variant can slip past the pattern entirely. HtmlSanitizer parses into a real DOM tree first, so it enforces an explicit tag allowlist AND a per-tag attribute allowlist AND validates the surviving href value, removing or unwrapping everything else -- an <a onclick="..." href="javascript:alert(1)">click</a> comes out as a plain, attribute-stripped <a> with no href at all, since javascript: fails the http(s):// check.
if (!in_array($tag, self::ALLOWED_TAGS, true)) { /* unwrap */ }
Common mistake: strip_tags($html, '<a>') alone would let that same malicious <a onclick=...> through completely untouched -- strip_tags() only ever decides which TAGS survive, never which attributes on them do.
<?php
declare(strict_types=1);
$rawHtmlFromTheWeb = '<div><p>Check <a href=/pricing>pricing</a and <a href="/docs">docs</p></div>';
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($rawHtmlFromTheWeb, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
foreach ($dom->getElementsByTagName('a') as $link) {
echo $link->getAttribute('href') . PHP_EOL;
}<?php
declare(strict_types=1);
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($productListingHtml);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
foreach ($xpath->query('//div[@class="product"]//h2') as $heading) {
echo trim($heading->textContent) . PHP_EOL;
}<?php
declare(strict_types=1);
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML(
'<?xml encoding="utf-8" ?><div><p>Hello</p></div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$p = $dom->getElementsByTagName('p')->item(0);
$p->setAttribute('class', 'greeting');
$container = $dom->getElementsByTagName('div')->item(0);
echo $dom->saveHTML($container) . PHP_EOL;<?php
declare(strict_types=1);
// UNSAFE: text-pattern matching, not structural understanding.
function sanitizeNaive(string $html): string
{
$html = preg_replace('#<script[^>]*>.*?</script>#is', '', $html);
return strip_tags($html, '<b><i><a><p>');
}
// SAFE: parses first, walks the real tree, enforces an allowlist
// per tag AND per attribute.
final class HtmlSanitizer
{
private const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'br'];
private const ALLOWED_ATTRS = ['a' => ['href']];
public static function sanitize(string $html): string
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML(
'<?xml encoding="utf-8" ?><div>' . $html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$wrapper = $dom->getElementsByTagName('div')->item(0);
self::clean($wrapper);
$out = '';
foreach ($wrapper->childNodes as $child) {
$out .= $dom->saveHTML($child);
}
return $out;
}
private static function clean(DOMNode $node): void
{
foreach (iterator_to_array($node->chil
…Text-Pattern Sanitizing vs. DOM-Walking Sanitizing
Sanitizing is a structural problem -- solve it with a structural tool (the DOM), not a text-pattern tool (regex).
Regex / strip_tags() DOM-based allowlist
---------------------- ---------------------
Treats HTML as TEXT Parses HTML into a real TREE first
Pattern-matches known-bad shapes Walks every actual element/attribute
Blind to attributes on allowed tags Checks attributes per allowed tag too
Obfuscated/nested markup can slip through Structure is already resolved by the parser
<a onclick=alert(1) href=/x>ok</a> <a href="/x">ok</a> <- onclick strippedImportant Rules
Call libxml_use_internal_errors(true) before loadHTML() on any real-world HTML, and libxml_clear_errors() after handling them.
Use LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD when loading a fragment, to avoid an unwanted implied wrapper and DOCTYPE.
Don't assume loadHTML() not throwing means the tree matches what you expected — check that expected nodes actually exist.
Sanitize HTML with an explicit allowlist of tags AND allowed attributes per tag, walked against the parsed DOM — never with regex or strip_tags() alone.
Validate the values of attributes you keep (e.g. require an href to start with http(s)://), not just their names.
For fragment output, serialize each top-level child with saveHTML($node) rather than the whole document.
There's no built-in streaming HTML parser in PHP — loadHTML() always builds a full tree; plan accordingly for very large documents.
Where This Shows Up
Every rich-text comment box, forum post editor, or CMS 'paste from Word' feature needs exactly this kind of DOM-based allowlist sanitization before storing or rendering user-supplied markup — it's one of the highest-consequence pieces of code in any application that accepts formatted user content, since getting it wrong is a direct path to stored cross-site scripting. Web scraping and content-extraction tools (pulling article text or pricing data out of third-party pages) rely on the same loadHTML() + DOMXPath combination to handle real, imperfect HTML from sites you don't control.
function sanitize(string $html): string { $html = preg_replace('#<script.*?</script>#is', '', $html); return strip_tags($html, '<a><img>'); }
Parse with DOMDocument, walk the tree removing/unwrapping disallowed tags, and strip disallowed attributes (and validate the ones you keep) per allowed tag — as in the HtmlSanitizer example.
strip_tags() decides which tags survive but does nothing about attributes on the tags it allows through — an allowed <a> or <img> can still carry onerror=, onclick=, or a javascript: URI straight past it untouched. A regex targeting <script> also only catches that one specific, unobfuscated shape, while a DOM-based walk operates on the document's actual resolved structure regardless of how the original markup was written.
$dom->loadHTML($fragmentHtml); echo $dom->saveHTML(); // serializes the WHOLE document, including an implied <html><body> wrapper you never wanted
$dom->loadHTML($fragmentHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); echo $dom->saveHTML($dom->getElementsByTagName('body')->item(0) ?? $dom->documentElement);
Without LIBXML_HTML_NOIMPLIED, loadHTML() silently wraps a bare fragment in a full document structure, and calling saveHTML() with no argument serializes that entire structure back out — producing an unwanted <html><body> wrapper around what was meant to be a small snippet.
Why is a DOM-based approach preferred over regex/strip_tags() for sanitizing user-supplied HTML? A. It runs faster in every case B. It parses the markup into a real tree first, so it can enforce an allowlist over both tags and their attributes structurally, instead of pattern-matching text that obfuscated or unusually-formed markup can slip past C. strip_tags() is deprecated in PHP 8.2 D. Regex cannot be used inside PHP functions at all
B. It parses the markup into a real tree first, so it can enforce an allowlist over both tags and their attributes structurally, instead of pattern-matching text that obfuscated or unusually-formed markup can slip past — strip_tags() only ever decides which tags survive — it does nothing about dangerous attributes on the tags it allows through, and regex-based tag stripping can miss obfuscated or nested variants. A DOM-based sanitizer works against the document's actual resolved structure and can check attributes, not just tag names.
What does passing LIBXML_HTML_NOIMPLIED to loadHTML() accomplish when parsing an HTML fragment? A. It disables all error reporting B. It prevents loadHTML() from wrapping the given fragment in an implied <html><body> structure and default DOCTYPE C. It converts the fragment to valid XML automatically D. It makes the parser reject malformed HTML instead of recovering from it
B. It prevents loadHTML() from wrapping the given fragment in an implied <html><body> structure and default DOCTYPE — By default, loadHTML() wraps whatever you pass it in a full implied document structure even if you only gave it a fragment. LIBXML_HTML_NOIMPLIED (paired with LIBXML_HTML_NODEFDTD) keeps the resulting tree matching the fragment you actually supplied.
Why is libxml_use_internal_errors(true) considered essentially mandatory before calling loadHTML() on real-world HTML? A. It's required for loadHTML() to work at all B. Real-world HTML routinely triggers many parser warnings for common malformation, and this call suppresses them in favor of structured errors you can inspect or discard deliberately C. It disables the HTML parser's error-recovery behavior D. It is only needed when using DOMXPath, not DOMDocument directly
B. Real-world HTML routinely triggers many parser warnings for common malformation, and this call suppresses them in favor of structured errors you can inspect or discard deliberately — Without it, every quirk in real-world HTML (missing wrapper tags, unclosed elements, unrecognized attributes) raises a separate PHP warning — unusable noise at any real scale. libxml_use_internal_errors(true) converts those into structured error objects retrievable via libxml_get_errors(), which you can inspect or simply discard.
Extend the HtmlSanitizer example to also allow <img> tags with only a src attribute, but only when that src is an https:// URL (never http://, and never a data: URI). Write the check that would replace/extend the existing href validation logic.
private const ALLOWED_TAGS = ['b','i','em','strong','a','p','br','img']; private const ALLOWED_ATTRS = ['a' => ['href'], 'img' => ['src']]; // inside the attribute loop: $isSafeSrc = $attr->name === 'src' && preg_match('#^https://#i', $attr->value) === 1; if (!in_array($attr->name, $allowedAttrs, true) || ($attr->name === 'href' && !$isSafeHref) || ($attr->name === 'src' && !$isSafeSrc)) { $child->removeAttribute($attr->name); }
Add 'img' to ALLOWED_TAGS and 'img' => ['src'] to ALLOWED_ATTRS, then add a parallel isSafeSrc check alongside isSafeHref that requires the value to start with exactly 'https://' rather than the broader http(s):// pattern used for href.