To parse HTML in PHP with DOMDocument, load the HTML with loadHTML(), then find elements with getElementsByTagName() or XPath queries through DOMXPath, and read or change them through the DOM API. On PHP 8.4 and later, the new Dom\HTMLDocument class is usually the better choice: it parses HTML5 correctly, handles UTF-8 without tricks and supports CSS selectors with querySelector().
This guide covers both, with examples for extracting text, tables, links and elements by class, modifying a page, and handling the two problems that trip up almost everyone using DOMDocument: HTML5 warnings and broken UTF-8 characters. I ran every example on PHP 8.5.12, and the outputs shown are real.
DOMDocument vs Dom\HTMLDocument
PHP’s DOM extension is enabled in almost every installation, so there is usually nothing to install; php -m | grep dom confirms it. It now offers two APIs:
DOMDocument (legacy) | Dom\HTMLDocument (PHP 8.4+) | |
|---|---|---|
| HTML parser | libxml2, based on HTML 4 | Lexbor, follows the HTML5 specification |
HTML5 tags like <nav>, <main> | Warnings such as “Tag nav invalid” | Parsed correctly |
| UTF-8 without a meta charset | Garbled unless you add a workaround | Correct |
| Find elements | getElementsByTagName(), getElementById(), DOMXPath | All of those plus querySelector() and querySelectorAll() |
| Convenience | Basic DOM Level 3 methods | Modern methods: append(), remove(), classList, closest() |
| Use when | You must support PHP 8.3 or older | You are on PHP 8.4 or newer |
PHP 8.4’s release notes describe the new API as standards-compliant HTML5 parsing that fixes several long-standing DOM bugs. The old classes still work and are not deprecated.
The Sample HTML
All examples parse this page, saved as page.html:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Price list</title></head>
<body>
<nav><a href="/">Home</a> <a href="/about">About</a></nav>
<main>
<h1>Café prices</h1>
<table id="prices">
<tr><th>Item</th><th>Price</th></tr>
<tr><td>Espresso</td><td>€2.50</td></tr>
<tr><td>Flat white</td><td>€3.20</td></tr>
</table>
<p class="note">Prices include VAT.</p>
</main>
</body>
</html>Parsing HTML with DOMDocument
Loading HTML (and why you see warnings)
The simplest version loads the file and reads the heading:
<?php
$html = file_get_contents('page.html');
$dom = new DOMDocument();
$dom->loadHTML($html); // no error handling yet
echo $dom->getElementsByTagName('h1')->item(0)->textContent, PHP_EOL;It works, but it also prints a warning for each HTML5 element, because libxml2’s parser predates HTML5:
Warning: DOMDocument::loadHTML(): Tag nav invalid in Entity, line: 5
Warning: DOMDocument::loadHTML(): Tag main invalid in Entity, line: 6
Café pricesThe elements are still parsed and usable; the warnings are just noise. Call libxml_use_internal_errors(true) before loading to collect them instead of printing them, then inspect them with libxml_get_errors() if you need to. Note that DOMDocument does not throw exceptions for malformed HTML, so wrapping it in try/catch does nothing for these warnings.
The UTF-8 problem
If the HTML has no <meta charset>, as with most fragments, loadHTML() assumes ISO-8859-1 and garbles every non-ASCII character:
<?php
$fragment = '<p>Café: €2.50</p>'; // no <meta charset>
$dom = new DOMDocument();
$dom->loadHTML($fragment);
echo "loadHTML: ", $dom->textContent, PHP_EOL;
$dom = new DOMDocument();
$dom->loadHTML('<?xml encoding="UTF-8">' . $fragment);
echo "with UTF-8 prefix: ", $dom->textContent, PHP_EOL;
$doc = Dom\HTMLDocument::createFromString($fragment, LIBXML_NOERROR);
echo "Dom\\HTMLDocument: ", $doc->body->textContent, PHP_EOL;loadHTML: Café: â¬2.50 (garbled)
with UTF-8 prefix: Café: €2.50
Dom\HTMLDocument: Café: €2.50Prefixing the string with <?xml encoding="UTF-8"> is the standard workaround for DOMDocument. The new parser simply gets it right.
Finding elements with DOMXPath
DOMDocument has no method to find elements by class or attribute (there is no getElementsByAttribute(), despite what some tutorials say). For anything beyond tag names and IDs, use DOMXPath. This script extracts the heading, the table rows, elements with a class, and every link:
<?php
$html = file_get_contents('page.html');
libxml_use_internal_errors(true); // collect parser warnings instead of printing them
$dom = new DOMDocument();
// Prefix tells libxml the input is UTF-8 (it otherwise assumes ISO-8859-1)
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
$warnings = libxml_get_errors();
libxml_clear_errors();
echo count($warnings), " parser warnings, first: ", trim($warnings[0]->message ?? 'none'), PHP_EOL;
$xpath = new DOMXPath($dom);
echo $xpath->query('//h1')->item(0)->textContent, PHP_EOL;
// Extract the table rows into an array
foreach ($xpath->query('//table[@id="prices"]//tr[td]') as $row) {
$cells = $row->getElementsByTagName('td');
printf("%s => %s\n", $cells->item(0)->textContent, $cells->item(1)->textContent);
}
// Elements with a given class
foreach ($xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " note ")]') as $el) {
echo "note: ", $el->textContent, PHP_EOL;
}
// All link URLs
foreach ($dom->getElementsByTagName('a') as $a) {
echo "link: ", $a->getAttribute('href'), PHP_EOL;
}Output:
2 parser warnings, first: Tag nav invalid
Café prices
Espresso => €2.50
Flat white => €3.20
note: Prices include VAT.
link: /
link: /aboutUseful XPath patterns:
| XPath | Selects |
|---|---|
//h1 | Every <h1> in the document |
//*[@id="prices"] | The element with that ID |
//a[@href] | Links that have an href |
//table//tr[td] | Table rows that contain data cells (skips the header row) |
//*[contains(concat(" ", normalize-space(@class), " "), " note ")] | Elements with the class note, even if they have other classes too |
//div[@class="card"]/h2/text() | The text of <h2> elements directly inside cards |
Parsing HTML with Dom\HTMLDocument (PHP 8.4+)
Dom\HTMLDocument is created with a static method rather than new, and supports CSS selectors directly. The same extraction, plus some edits, looks like this:
<?php
// PHP 8.4+: spec-compliant HTML5 parser with CSS selectors
$doc = Dom\HTMLDocument::createFromFile('page.html', LIBXML_NOERROR);
echo $doc->querySelector('h1')->textContent, PHP_EOL;
foreach ($doc->querySelectorAll('#prices tr:has(td)') as $row) {
[$item, $price] = $row->querySelectorAll('td');
echo "{$item->textContent} => {$price->textContent}", PHP_EOL;
}
echo "note: ", $doc->querySelector('p.note')->textContent, PHP_EOL;
// Modify the document
$p = $doc->createElement('p');
$p->textContent = 'Updated today.';
$p->classList->add('updated');
$doc->querySelector('main')->append($p);
$doc->querySelector('p.note')->remove();
$doc->querySelector('h1')->setAttribute('id', 'title');
echo $doc->saveHtml($doc->querySelector('main')), PHP_EOL;Output:
Café prices
Espresso => €2.50
Flat white => €3.20
note: Prices include VAT.
<main>
<h1 id="title">Café prices</h1>
<table id="prices">
<tbody><tr><th>Item</th><th>Price</th></tr>
<tr><td>Espresso</td><td>€2.50</td></tr>
<tr><td>Flat white</td><td>€3.20</td></tr>
</tbody></table>
<p class="updated">Updated today.</p></main>Notice two things. There were no HTML5 warnings and no encoding workaround. And the output contains a <tbody> that was not in the source: the HTML5 parsing rules insert it, exactly as browsers do, so the parsed tree matches what you see in the browser’s developer tools. Keep that in mind when you write selectors.
Common Tasks
Modify text and attributes
$el->textContent = 'New text'; // replaces the element's text (escaped safely)
$el->setAttribute('class', 'highlight');
$el->removeAttribute('style');Add and remove elements
// Legacy API
$div = $dom->createElement('div');
$div->appendChild($dom->createTextNode('Hello'));
$dom->getElementsByTagName('body')->item(0)->appendChild($div);
$old->parentNode->removeChild($old);
// PHP 8.4+ API
$doc->body->append($doc->createElement('div'));
$doc->querySelector('.ad')?->remove();Use textContent (or createTextNode()) for text that comes from users, so it is escaped automatically. Setting nodeValue on an element also replaces its text, but treat HTML strings from outside as untrusted.
Get the HTML back out
echo $dom->saveHTML(); // whole document
echo $dom->saveHTML($element); // one element and its children (outer HTML)
echo $doc->saveHtml(); // Dom\HTMLDocument equivalentParse a page from a URL
Fetch the HTML with an HTTP client (cURL or Guzzle) so you control timeouts, headers and errors, then parse the string. Avoid passing remote URLs straight to loadHTMLFile(). Respect the site’s terms and robots.txt, and remember that pages built with JavaScript need a real browser instead; see web scraping with Selenium.
$ch = curl_init('https://example.com/');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true]);
$html = curl_exec($ch);
if ($html !== false) {
$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
echo $doc->querySelector('h1')?->textContent;
}Tips and Troubleshooting
- Lots of warnings: use
Dom\HTMLDocument, orlibxml_use_internal_errors(true)with DOMDocument. - Strange characters like é: an encoding problem; add the
<?xml encoding="UTF-8">prefix or switch to the new parser. - A selector finds nothing: print what was parsed with
saveHTML(). The parser may have restructured invalid markup, added<tbody>, or the content may be added by JavaScript and not exist in the source at all. - Extra
<html><body>around a fragment: with DOMDocument, passLIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTDtoloadHTML(), or output just the elements you need withsaveHTML($node). The second option also avoids printing the<?xml encoding="UTF-8">line that the encoding workaround leaves in full-document output. - Large documents: DOM parsing loads the whole tree into memory. For very large or streaming XML, use
XMLReaderinstead.
For background on what a parser does, read what HTML parsing means and how the DOM tree is structured.
Conclusion
DOMDocument has been PHP’s built-in HTML parser for years, and it still works if you handle its two quirks: silence HTML5 warnings with libxml_use_internal_errors() and force UTF-8 with the encoding prefix, then use DOMXPath for any non-trivial query. If you are on PHP 8.4 or newer, start with Dom\HTMLDocument instead: it parses HTML the way browsers do, needs no workarounds and lets you use the same CSS selectors you already know.
FAQs
What is DOMDocument in PHP?
A built-in class from PHP’s DOM extension that loads HTML or XML into a tree of nodes, which you can search, read and modify, then save back to a string or file.
How do I stop “Tag nav invalid” warnings from loadHTML?
Call libxml_use_internal_errors(true) before loading, and libxml_clear_errors() afterwards. The warnings come from libxml2 not knowing HTML5 elements; the elements are still parsed. On PHP 8.4+, Dom\HTMLDocument parses HTML5 without these warnings.
Why does DOMDocument break UTF-8 characters?
loadHTML() assumes ISO-8859-1 when the HTML does not declare a charset. Prepend <?xml encoding="UTF-8"> to the string, or use Dom\HTMLDocument, which handles UTF-8 correctly.
How do I find elements by class with DOMDocument?
Use DOMXPath, for example //*[contains(concat(” “, normalize-space(@class), ” “), ” note “)]. DOMDocument has no getElementsByClassName method. With Dom\HTMLDocument you can use querySelectorAll(“.note”).
What is Dom\HTMLDocument?
A class added in PHP 8.4 that parses HTML according to the HTML5 specification and supports modern DOM methods, including querySelector, querySelectorAll, classList, append and remove.
Can DOMDocument parse pages that use JavaScript?
No. It only sees the HTML returned by the server. Content added by JavaScript after the page loads requires a headless browser such as Selenium or Playwright.








