Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Htmlpagedom Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The README's method table — it is essentially the jQuery API surface (addClass, append, wrap, closest, is, …).
  2. HtmlPage::getBody() / getHead() — these return HtmlPageCrawler instances, so everything chains.
  3. Symfony's 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.

Implementation Patterns

Fragment mode vs. document mode

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

Chain setters, snapshot getters

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();

Bulk attribute / class operations

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

Injecting content

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.

Inline styles

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

Symfony integration points

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

  • Testing: take a DOM snapshot with saveHTML() and assert against it; or convert an HtmlPageCrawler back into a plain Crawler-compatible workflow for form/button selection.
  • Console commands: parse an exported static site, rewrite asset paths, write back with saveHTMLFile().
  • Queue jobs: normalise vendor HTML before storing it in the database.

Reusable transformations as a subclass

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.

Gotchas and Tips

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:

  1. $crawler->getNode() → raw DOM for one-off needs.
  2. Subclass and add domain methods (recommended — survives chaining, and survives Symfony majors).
  3. Wrap the whole thing in a service that exposes named transformations, so your controllers never see CSS selectors.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource