Every PHP developer who has scraped a page or rewritten an email template knows the ritual: new DOMDocument, silence the libxml warnings, prepend a <meta> charset hack, and pray the accents survive. DOMDocument is an XML parser wearing an HTML costume, and it has mangled real-world markup for two decades. PHP 8.4 finally fixes it with Dom\HTMLDocument — a spec-compliant HTML5 parser built on Lexbor, with the same querySelector API you already use in the browser.
Why DOMDocument mangled your HTML5#
DOMDocument is built on libxml, an XML parser. Feed it HTML5 and three things go wrong. It throws warnings on any tag or attribute it considers invalid, so you end up wrapping every load in libxml_use_internal_errors(true). It assumes Latin-1, so UTF-8 content turns to mojibake unless you inject a <meta charset> or an XML encoding declaration. And it only offers XPath for querying, which is powerful but verbose for everyday .class and #id lookups.
Here is the extraction dance most of us have copy-pasted for years:
$html = '<p class="name">José Núñez</p>';
$dom = new DOMDocument();
libxml_use_internal_errors(true); // swallow HTML5 warnings
$dom->loadHTML(
'<?xml encoding="UTF-8">' . $html, // the charset hack
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
foreach ($xpath->query('//p[@class="name"]') as $node) {
echo $node->textContent; // José Núñez — if the hack worked
}
Miss the encoding hack and José comes back as José. That is the tax DOMDocument charges.
Dom\HTMLDocument: a real HTML5 parser in PHP 8.4#
PHP 8.4 ships a second DOM implementation in the Dom\ namespace, backed by Lexbor — a C-based, spec-compliant HTML5 engine. It parses markup the way a browser does: no warnings on valid HTML5, correct UTF-8 by default, and CSS selectors built in. PHP 8.4 was a generous release — it also gave us array_find, array_any and array_all — but the DOM overhaul is the one that quietly deletes twenty years of workarounds.
The same extraction, rewritten:
use Dom\HTMLDocument;
$html = '<p class="name">José Núñez</p>';
$dom = HTMLDocument::createFromString($html);
foreach ($dom->querySelectorAll('.name') as $node) {
echo $node->textContent; // José Núñez
}
No warning suppression. No charset hack. No XPath. The accents just work.
Three ways to create a document#
Dom\HTMLDocument has no public constructor. You reach for one of three static factory methods depending on where your markup comes from:
use Dom\HTMLDocument;
// 1. From a string — scraped HTML, an API response, a rendered Blade view
$dom = HTMLDocument::createFromString($html);
// 2. From a file path or any stream wrapper
$dom = HTMLDocument::createFromFile(storage_path('exports/invoice.html'));
// 3. Empty — build the document up node by node
$dom = HTMLDocument::createEmpty();
Because these are named constructors rather than new, you skip the old two-step "instantiate then loadHTML" pattern entirely — and since there is no object to chain off, the new-without-parentheses syntax PHP 8.4 also added never comes into play here.
createFromFile reads and decodes in a single call, so UTF-8 files stay intact with zero ceremony:
use Dom\HTMLDocument;
// invoice.html contains: <h1>Facture — José Núñez</h1>
$dom = HTMLDocument::createFromFile(storage_path('exports/invoice.html'));
echo $dom->querySelector('h1')->textContent; // Facture — José Núñez
Querying with querySelector and querySelectorAll#
If you have written frontend JavaScript, you already know the API. querySelector() returns the first matching Dom\Element or null; querySelectorAll() returns a list you can foreach:
use Dom\HTMLDocument;
$dom = HTMLDocument::createFromString($productHtml);
// First match, or null
$title = $dom->querySelector('h1.product-title')?->textContent;
// Every match
$prices = [];
foreach ($dom->querySelectorAll('[data-price]') as $el) {
$prices[] = $el->getAttribute('data-price');
}
The old getElementsByTagName() still works when you only need a tag name:
foreach ($dom->getElementsByTagName('p') as $paragraph) {
echo $paragraph->textContent . PHP_EOL;
}
XPath hasn't gone anywhere — Dom\XPath exists for the queries CSS selectors can't express — but for the 90% case of classes, IDs and attributes, querySelectorAll is shorter and reads like the DOM you already know.
Gotchas and edge cases#
It silently completes the document. Hand createFromString a bare fragment and Lexbor wraps it in <html><body> and adds a doctype, exactly like a browser. saveHtml() will then return more than you put in. Pass the libxml flags to opt out:
use Dom\HTMLDocument;
$dom = HTMLDocument::createFromString(
'<p>Just this</p>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
echo $dom->saveHtml(); // <p>Just this</p>
It is not a drop-in replacement. The Dom\* classes are a parallel hierarchy — Dom\Element, Dom\Node, Dom\HTMLDocument — with a similar but ultimately different interface to the old DOM* classes. You cannot type-hint the old DOMElement and pass it a new Dom\Element. Migrate a whole parsing routine at once rather than swapping one line at a time.
Mind your tooling. The Dom\ stubs landed slightly after 8.4 itself, so make sure PHPStan, your IDE and any polyfills are current — otherwise you'll see phantom "undefined method" noise on querySelector.
Encoding is detected, not assumed. createFromString guesses the encoding from the content. When in doubt, pass it explicitly with the third argument (createFromString($html, overrideEncoding: 'UTF-8')) rather than trusting detection on a short fragment.
Wrapping up#
If you maintain any code that calls loadHTML, Dom\HTMLDocument is the first thing to reach for on PHP 8.4: drop the libxml_use_internal_errors calls, delete the <meta> charset hack, and swap XPath for querySelectorAll. Keep the legacy DOMDocument — or the new Dom\XMLDocument — only where you are genuinely parsing XML. From there, PHP 8.4's lazy objects and the #[\Deprecated] attribute are the next two features worth folding into a codebase audit.
FAQ#
What is Dom\HTMLDocument in PHP 8.4?
Dom\HTMLDocument is a new class in PHP 8.4 that parses and manipulates HTML5 documents using the Lexbor engine instead of libxml. It lives in the Dom\ namespace alongside Dom\XMLDocument, and it exposes browser-style methods like querySelector and querySelectorAll. It is the first time core PHP has shipped a genuinely spec-compliant HTML5 parser.
How is Dom\HTMLDocument different from DOMDocument?
The old DOMDocument is an XML parser built on libxml; it warns on valid HTML5, corrupts UTF-8 without a charset hack, and only supports XPath. Dom\HTMLDocument is built on Lexbor, follows the HTML5 spec, handles UTF-8 correctly by default, and adds CSS-selector querying. They are separate class hierarchies, so the new one is not a drop-in replacement — you migrate a routine wholesale rather than swapping a single line.
How do I use querySelector in PHP 8.4?
Create a document with Dom\HTMLDocument::createFromString() or ::createFromFile(), then call querySelector() for the first match or querySelectorAll() for all matches, passing any CSS selector. querySelector returns a Dom\Element or null, while querySelectorAll returns an iterable list. Read text with the ->textContent property and attributes with ->getAttribute(), exactly as you would in browser JavaScript.
Does PHP 8.4 fix the DOMDocument UTF-8 encoding problem?
Yes. Dom\HTMLDocument handles UTF-8 correctly out of the box, so the <?xml encoding> and <meta charset> hacks that plagued DOMDocument are no longer needed. It detects the source encoding automatically, and you can override it with the third argument to createFromString when you need to be explicit. The legacy DOMDocument keeps its old behaviour, so the fix only applies once you switch to the new class.
Can I still use the old DOMDocument in PHP 8.4?
Yes. DOMDocument and the rest of the DOM* classes remain fully available and unchanged in PHP 8.4, so existing code keeps working untouched. Reach for the legacy class — or the new Dom\XMLDocument — when you are parsing XML, and use Dom\HTMLDocument for anything HTML. Running both side by side in the same project is perfectly fine while you migrate.