wa72/htmlpagedom
wa72/htmlpagedom is a PHP library that extends DOMDocument for easy HTML manipulation with jQuery-style methods and CSS selectors. Load HTML strings or files, then query and modify elements using familiar find, filter, addClass, attr, and append APIs, powered by Symfony CssSelector.
Install
composer require wa72/htmlpagedom
This pulls in symfony/dom-crawler, symfony/css-selector and ext-dom.
Requirements (v3.1.0+): PHP 8.1 minimum — up from 8.0 in earlier 3.x releases — and the package now tracks Symfony 8 alongside Symfony 5/6/7. This is a compatibility-only release: no API changes, no deprecations, nothing to refactor. If you pin symfony/dom-crawler yourself, drop the hard pin (or widen it to include ^8.0) and let Composer resolve; otherwise a Symfony 8 app will hit a conflict on upgrade. On PHP 8.0 you must stay on the previous 3.0.x release.
You almost never instantiate Symfony's Crawler directly again — you use one of three classes:
| Class | Use it for |
|---|---|
HtmlPageCrawler |
A fragment or any node set. Static ::create($html) is your entry point. |
HtmlPageDom |
The base class (same API); HtmlPageCrawler is kept as the familiar name. |
HtmlPage |
A complete document — parses <html>/<head>/<body>, preserves doctype, handles charset, gives you getHead() / getBody() / setTitle(). |
First thing to try — the canonical "post-process rendered HTML" use case:
use Wa72\HtmlPageDom\HtmlPage;
$page = new HtmlPage($renderedHtml); // full document from Twig/Blade output
$page->filter('a[href^="http"]')
->setAttribute('rel', 'noopener noreferrer')
->setAttribute('target', '_blank');
$page->filter('img')->setAttribute('loading', 'lazy');
echo $page->saveHTML(); // or $page->saveHTMLFile($path)
Where to look first
addClass, append, wrap, closest, is, …).HtmlPage::getBody() / getHead() — these return HtmlPageCrawler instances, so everything chains.Crawler docs — all of filterXPath(), evaluate(), selectLink(), selectButton(), form submission, etc. are inherited and still work.First real use case: don't build pages with this library. Use it to mutate HTML you already have — CMS output, email templates, third-party widgets, scraped markup, or HTML snapshots in tests.
Pick deliberately, because saveHTML() behaves differently.
// Fragment: wraps in a synthetic document, saveHTML() returns only your fragment
$c = HtmlPageCrawler::create('<div class="card"><h2>Title</h2></div>');
$c->filter('h2')->addClass('card__title');
echo $c->saveHTML(); // <div class="card"><h2 class="card__title">Title</h2></div>
// Document: keeps doctype, <head>, charset meta, and full structure
$page = new HtmlPage($fullHtml);
$page->getHead()->append('<link rel="preload" href="/font.woff2" as="font" crossorigin>');
$page->getBody()->filter('.legacy')->remove();
Rule of thumb: if you have <!DOCTYPE, use HtmlPage. If you have a partial, use HtmlPageCrawler::create().
Setters mutate the underlying DOM immediately; filter() returns a new crawler over a node list. So the original object always reflects your edits, even if you threw the chain result away.
$crawler = HtmlPageCrawler::create($html);
$crawler->filter('table')->addClass('table')->addClass('table--striped');
// $crawler is mutated even though we didn't reassign
// Getters read from the *first* node in the set:
$href = $crawler->filter('a')->getAttribute('href');
$count = $crawler->filter('tr')->count();
$crawler->filter('.js-toggle')
->removeClass('js-toggle')
->setAttribute('data-controller', 'toggle');
$crawler->filter('input[type=text]')
->each(function (HtmlPageCrawler $node, int $i) {
$node->setAttribute('autocomplete', 'off');
$node->setAttribute('id', 'field-' . $i);
});
each() hands you a crawler wrapping a single node — treat it as read-modify-write, not a place to run queries across the whole document.
append / prepend / before / after / replaceWith accept an HTML string, a DOMNode, or another Crawler:
$partial = HtmlPageCrawler::create($this->renderView('partials/banner.html.twig'));
$page->getBody()->filter('main')->prepend($partial);
$page->getBody()->filter('footer')->replaceWith('<footer class="site-footer">…</footer>');
Passing the crawler rather than a string avoids double-parsing and keeps node identity intact.
$crawler->filter('.hero')->css('background-color', '#0b5')->css('color', '#fff');
$crawler->filter('.muted')->css(['opacity' => '0.7', 'font-size' => '0.9em']);
Useful for email templates where you must inline CSS — but for production email work, run a dedicated inliner (e.g. tijsverkoyen/css-to-inline-styles) and use this library only for structural surgery.
// Kernel response listener / middleware — rewrite outgoing HTML
public function onKernelResponse(ResponseEvent $event): void
{
$response = $event->getResponse();
$type = $response->headers->get('Content-Type', '');
if (!str_contains($type, 'text/html')) {
return;
}
$page = new HtmlPage($response->getContent());
$page->filter('a[href^="http"]')->setAttribute('rel', 'noopener');
$response->setContent($page->saveHTML());
}
This listener pattern is unchanged from Symfony 5 through 8 — the ResponseEvent signature and Response::getContent()/setContent() contract are stable, so a middleware written today keeps working after you bump Symfony.
saveHTML() and assert against it; or convert an HtmlPageCrawler back into a plain Crawler-compatible workflow for form/button selection.saveHTMLFile().class EmailCrawler extends HtmlPageCrawler
{
public function absolutizeUrls(string $base): self
{
$this->filter('img[src^="/"]')->each(
fn (self $n) => $n->setAttribute('src', $base . $n->getAttribute('src'))
);
return $this;
}
}
filter() instantiates new static(...) inside Symfony's Crawler, so a subclass survives every chain call — chained results are the same class. This is the cheapest extension point you have.
Upgrading to v3.1.0 is a dependency change, not a code change. The only things that can break are Composer constraints: PHP < 8.1, or a hard pin on symfony/dom-crawler ^5/^6/^7 in an app that has moved to Symfony 8. Widen the constraint (or remove it — you don't reference Crawler directly anyway) and re-run your HTML-transformation tests, since a Symfony major bump can shift libxml/DomCrawler edge-case behaviour even when this package's API is stable.
html() means something different than in Symfony. This package follows jQuery: $crawler->html() returns the inner HTML of the first node and sets inner HTML when given an argument. Symfony's Crawler::html() returns outer HTML. If you have code or tests written against raw DomCrawler, this is the single most likely silent breakage — and it's the one to re-check when you cross a Symfony major version. Use the outer-HTML accessor (or saveHTML()) when you need the element itself included.
Setters on empty selections fail silently. $crawler->filter('#typo')->addClass('x') is a no-op, no exception. In pipelines, insert guards:
if ($page->filter('main')->count() === 0) {
throw new \RuntimeException('Unexpected page structure');
}
filter() throws when the CSS selector is invalid, but not when it simply matches nothing — so selector typos surface loudly while content typos do not. This matters more after a Symfony upgrade, where a silently-empty selection makes a broken transformation look like a successful one.
Charset bites on fragments. HtmlPageCrawler::create() has no <meta charset>, so libxml may assume Latin-1 and mangle UTF-8. For anything non-ASCII, prefer HtmlPage (which emits a charset meta) or prepend one yourself. On PHP 8.1+ — now the floor — avoid the old mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') trick entirely: it's deprecated since 8.2 and this library has its own handling.
libxml2 is an HTML4 parser. <article>, <section>, <main> survive as unknown elements, but HTML5 void elements that HTML4 doesn't know — <source>, <track>, <wbr>, <picture>'s children — are not recognised as self-closing. A <source> without a matching </source> can swallow all following siblings. Symptom: your <picture> block "eats" the rest of the container after a round-trip. Fix by giving the source element an explicit close, or by post-processing those blocks with string logic instead.
<script> and <style> don't like being moved. Appending a <script> string to a fragment may place it where you didn't expect, and HtmlPage may relocate head-ish elements. For script injection, create a real DOMElement and import it, or re-parse the whole document.
text() escapes on write, html() does not. $node->text('<b>hi</b>') produces escaped text; $node->html('<b>hi</b>') produces markup. Mixing them up is a classic XSS-adjacent mistake when transforming user-supplied content.
Performance: parse once, chain everything. Each HtmlPageCrawler::create('<div>…</div>') inside a loop re-runs libxml and the fragment wrapper. For 1000-row tables, build the string once, parse once, then use each() or a single filter(). If you're processing multi-megabyte documents repeatedly, a raw DOMDocument + DOMXPath is measurably faster — this library's value is ergonomics, not throughput.
remove() empties the selection. After $crawler->filter('.ad')->remove(), that filtered crawler holds detached nodes. Re-query from the parent/document crawler, not from the removed set.
Debugging tools that actually help:
saveHTML() — dump the current state at any point in a chain.getCombinedPath() — the CSS path to the node, invaluable when "which element did I actually match?" is unclear.getNode() — drop to raw DOMNode for anything the jQuery API doesn't cover.filterXPath() / evaluate() — inherited from Symfony; use when a CSS selector can't express the relation (e.g. "the td after the one containing 'Total'").Immutability expectations. The API looks fluent like jQuery, but the crawler objects are node-set snapshots while the DOM is shared. Storing $a = $crawler->filter('p') and $b = $crawler->filter('p') gives you two views of the same nodes — mutating via $a is visible via $b. This is usually what you want, but it means makeCopy() (deep-clone the nodes) matters when you need a genuinely independent subtree, e.g. building repeated blocks from a template node.
Extension points, ranked by cost:
$crawler->getNode() → raw DOM for one-off needs.How can I help you explore Laravel packages today?