Scientyfic World

How to Parse HTML in PHP with DOMDocument (and PHP 8.4 HTMLDocument)

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...

Share:

Get an AI summary of this article

Feature Image for domdocument blog

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 parserlibxml2, based on HTML 4Lexbor, follows the HTML5 specification
HTML5 tags like <nav>, <main>Warnings such as “Tag nav invalid”Parsed correctly
UTF-8 without a meta charsetGarbled unless you add a workaroundCorrect
Find elementsgetElementsByTagName(), getElementById(), DOMXPathAll of those plus querySelector() and querySelectorAll()
ConvenienceBasic DOM Level 3 methodsModern methods: append(), remove(), classList, closest()
Use whenYou must support PHP 8.3 or olderYou 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é prices

The 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.50

Prefixing 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: /about

Useful XPath patterns:

XPathSelects
//h1Every <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 equivalent

Parse 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, or libxml_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, pass LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD to loadHTML(), or output just the elements you need with saveHTML($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 XMLReader instead.

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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts