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

Php Html Parser Laravel Package

thesoftwarefanatics/php-html-parser

A PHP HTML parser for loading, traversing, and manipulating HTML documents. It provides a simple API for extracting elements, attributes, and text, making it useful for scraping, DOM-style processing, and HTML cleanup in PHP applications.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require thesoftwarefanatics/php-html-parser

This is a fork of paquettg/php-html-parser that tracks the 1.8.x line (the current release is 1.8.1; the fork did not carry upstream 2.x forward as a stable tag). The namespace is still PHPHtmlParser, and the parser ships its own tokenizer/DOM tree — it does not use ext-dom/libxml, which is why it tolerates malformed markup well.

First use case — pull links out of a fragment

use PHPHtmlParser\Dom;

$dom = new Dom();
$dom->loadStr($html);                 // in-memory string
// $dom->loadFromFile('/path/page.html');
// $dom->loadFromUrl('https://example.com'); // network I/O inside the parser

foreach ($dom->find('a.product-link') as $a) {
    dump($a->getAttribute('href'), trim($a->text));
}

find() returns a PHPHtmlParser\Dom\Collection (ArrayAccess + Countable + IteratorAggregate), so count() and foreach both work.

Where to look first

  • vendor/thesoftwarefanatics/php-html-parser/src/PHPHtmlParser/Dom.php — the entry point; also holds the static settings/constants for option keys.
  • .../Dom/Node.php, .../Dom/HtmlNode.php, .../Dom/TextNode.php — node API (text, innerHtml, outerHtml, getAttribute, getChildren, parent, replaceChild).
  • .../Selector.php — the CSS selector engine (read this before writing complex selectors).
  • .../Dom/Collection.php — what find() hands back.
  • composer.json in the package — check the php constraint before upgrading PHP.

In Laravel, wrap it immediately rather than sprinkling new Dom() through controllers — see the next section.

Implementation Patterns

1. Adapter service (the most important pattern here)

Given the package's low activity, isolate it behind one class so you can swap in symfony/dom-crawler later without touching call sites.

namespace App\Support\Html;

use PHPHtmlParser\Dom;
use PHPHtmlParser\Dom\Collection;

class HtmlDocument
{
    private Dom $dom;

    public function __construct(string $html)
    {
        $this->dom = new Dom();
        $this->dom->loadStr($html, [
            'whitespaceTextNode' => false,
            'enforceEncoding'    => 'UTF-8',
        ]);
    }

    public function find(string $selector): Collection
    {
        return $this->dom->find($selector);
    }

    public function html(): string
    {
        return (string) $this->dom;
    }
}

Bind it as a transient, never a singleton — a Dom instance carries document state.

2. Fetch with Laravel's HTTP client, parse with the parser

Don't let loadFromUrl() do the network call. You lose retries, timeouts, Http::fake() in tests, proxies, and auth headers.

$response = Http::timeout(15)
    ->withHeaders(['User-Agent' => config('scraper.ua')])
    ->get($url);

$doc = new HtmlDocument($response->body());
$rows = $doc->find('table.results tbody tr');

In tests: Http::fake([...]) returns a fixture body, and the parser is exercised against stable HTML with zero network flakiness.

3. Selector-driven extraction with a row-then-field loop

The reliable pattern for tabular pages is: select the container first, then find() within each row.

foreach ($doc->find('ul.products > li') as $li) {
    $link  = $li->find('a')[0] ?? null;
    $price = $li->find('span.price')[0] ?? null;

    $items[] = [
        'name'  => $link ? trim($link->text) : null,
        'url'   => $link?->getAttribute('href'),
        'price' => $price ? trim($price->text) : null,
    ];
}

Supported selector features: tag names, .class, #id, [attr], [attr=value], ^=, $=, *=, !=, descendant ( ) and child (>) combinators. Not supported: pseudo-classes (:first-child, :nth-child, :not), sibling combinators (~, +), comma-separated selector lists (test this against your version before relying on it), and CSS namespaces.

4. Traversal when selectors aren't enough

$node = $doc->find('h2')[0];

$node->parent;            // ancestor
$node->getChildren();     // direct children (includes TextNodes)
$node->firstChild();
$node->nextSibling();
$node->getElementsByTagName('a');
$node->tag->name();       // 'div', 'a', ...

Note that find() on a node searches descendants only — it will not match the node itself.

5. Mutation and re-serialization

replaceChild() was broken in releases before 1.8.1 — it did not correctly splice the new node into the parent's children array. If you're on ≥ 1.8.1 (the current release), the obvious call works as expected:

use PHPHtmlParser\Dom\HtmlNode;
use PHPHtmlParser\Dom\TextNode;

$container = $doc->find('div#content')[0];
$container->setAttribute('data-processed', '1');
$container->addChild(new HtmlNode('hr', [], true));
$container->addChild(new TextNode('appended by the parser'));

// Reliable as of 1.8.1 (PR #21):
$old = $doc->find('span.legacy')[0] ?? null;
if ($old && $old->parent) {
    $old->parent->replaceChild($old, new HtmlNode('span', ['class' => 'modern'], true));
}

$clean = $container->outerHtml;   // or $node->innerHtml

Useful for "sanitize/rewrite a snippet before storing" jobs — e.g. stripping ad blocks from an ingested feed article. Pin ≥ 1.8.1 if you depend on replaceChild().

6. Queued ingestion in Laravel

Scraping belongs in a job, not a request:

class ImportProductPage implements ShouldQueue
{
    public function handle(): void
    {
        $html = Cache::remember("page:{$this->url}", now()->addHours(6), fn () =>
            Http::timeout(20)->get($this->url)->body()
        );

        $doc = new HtmlDocument($html);
        // ...extract, upsert
    }
}

Cache the raw HTML, not the parsed nodes — the tree isn't serializable and is memory-heavy to hold.

Gotchas and Tips

Low-activity fork — plan the exit. 1.8.1 was published as a maintenance release (replaceChild fix, meta-charset parsing fix, expanded Travis suite, plus small robustness changes) — so it isn't dead, but it's still bugfix-only. Pin it exactly ("thesoftwarefanatics/php-html-parser": "1.8.*" or a commit hash) and commit the lockfile. If PHP gains a minor that breaks it, upstream patches may lag.

Class-collision risk. The namespace is PHPHtmlParser. If any other dependency pulls in paquettg/php-html-parser (or its 2.x line), Composer will install both and you'll get duplicate-class/fatal errors. Check composer why paquettg/php-html-parser and add a conflict rule if needed.

PHP 8 deprecations. The codebase is PHP 7-era. Expect E_DEPRECATED noise (implicit nullable params, strlen(null), etc.). Don't silence globally — run the suite with E_ALL first, note what fires, then narrow the suppression to the parsing code path. Test on your target PHP version before deploying.

Global static settings leak. Options set via the static Dom::setSettings([...]) (as opposed to per-instance constructor options) persist for the whole process. Under Octane/queues that means one job's whitespaceTextNode => false silently affects the next request. Always pass options per instance.

Whitespace text nodes. Default keeps them, and they appear in getChildren() and count toward innerHtml. Setting whitespaceTextNode => false makes traversal cleaner but changes re-serialized output — don't enable it if you're round-tripping HTML for storage.

Encoding. Without mbstring the encoder detection gets rough. Set enforceEncoding => 'UTF-8', strip the BOM (ltrim($html, "\xEF\xBB\xBF")), and convert non-UTF-8 sources at the HTTP layer rather than hoping the parser guesses. 1.8.1 improves meta-tag charset detection to tolerate a trailing ; or whitespace (PR #7) — this matters when pages emit <meta charset="utf-8;"> — but you should still not rely on the parser to sniff encodings for arbitrary legacy pages. Mojibake in $node->text is almost always an input-encoding problem, not a selector problem.

<script> / <style> swallowing content. Like most hand-rolled tokenizers, an unbalanced < or an unclosed raw-text element can consume the rest of the document. Symptom: find('footer') returns nothing on a page that clearly has a footer. Bisect by feeding progressively truncated HTML through loadStr() and inspecting count($dom->getChildren()).

Memory. The entire tree lives in PHP objects — far heavier per node than libxml. Multi-megabyte pages plus a 128M memory_limit will fatal. Parse one document at a time, unset($dom) after extraction, and prefer extracting a sub-tree ($dom->find('main')[0]->innerHtml) and re-parsing that for big pages.

loadFromUrl() bypasses everything. No Http::fake(), no retry, no timeout config, no proxy — and it silently returns an empty document on failure rather than throwing in some paths. In 1.8.1 the URL/file heuristic was tightened (PR #5 guards is_file() against short strings), but it still swallows failures. Always check $dom->count() / selector results are non-empty before assuming success.

Debugging checklist, in order:

  1. strlen($html) and substr($html, 0, 500) — is the body actually what you expect (JS-rendered pages return a shell with no data)?
  2. $dom->getChildren() / $dom->root — did anything parse?
  3. (string) $dom->find('body')[0] — dump the tree the parser actually built; tag nesting is often not what the browser shows.
  4. Only then blame the selector.

$node->text vs innerHtml. text concatenates descendant text nodes (entities may remain as-is depending on the path); innerHtml gives raw markup. For display strings, pipe through html_entity_decode(trim($node->text), ENT_QUOTES | ENT_HTML5, 'UTF-8').

Extension points. Subclass Dom to override load behavior, subclass Selector to add missing selector syntax (the find() pipeline is easy to intercept), subclass HtmlNode/TextNode for custom rendering. Do this inside your adapter, not at call sites. The 1.8.1 test-suite expansion (PR #20) and the replaceChild fix (PR #21) both landed in the same place you'd be hooking into, so bump to 1.8.1 before subclassing HtmlNode and overriding child mutation.

Migration target if it bites you. symfony/dom-crawler + masterminds/html5 covers the same selectors (CssSelector is a superset of what this package supports) and is actively maintained. Keep the adapter's public surface (find, html, plus a small node DTO) so the swap is one file.

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.
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
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core