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

Simple Html Dom Laravel Package

voku/simple_html_dom

voku/simple_html_dom is a fast PHP HTML DOM parser and jQuery-style selector library. It wraps DOMDocument and Symfony CssSelector to find, extract, and manipulate HTML with UTF-8 and partial invalid-HTML support. Requires PHP 7.1+.

View on GitHub
Deep Wiki
Context7

Getting Started

Install via Composer; there is no service provider or config file to publish:

composer require voku/simple_html_dom

Everything lives in the voku\helper namespace. The entry point you will use 95% of the time is HtmlDomParser:

use voku\helper\HtmlDomParser;

$dom = HtmlDomParser::str_get_html($html);

$title = $dom->findOne('h1.product-title')?->text();
$price = $dom->findOne('span.price')?->text();

First things to read in vendor/voku/simple_html_dom/src/:

  1. HtmlDomParser.php — static factory (str_get_html, file_get_html, str_get_html_from_file) and global helpers.
  2. SimpleHtmlDom.php — the single-node object: find(), findOne(), findMulti(), text(), html(), outerHtml(), attribute access, and the magic properties ($el->href, $el->src, $el->plaintext).
  3. SimpleHtmlDomNode.php — the array-like collection returned by find().
  4. HtmlDomParserBase.php — the DOMDocument wrapper, including getDocument() when you need an XPath escape hatch.

First realistic use case in a Laravel app: you already have HTML (an email body, a scraped page fetched with Http::get(), a stored snapshot, a rich-text field from the DB) and you need structured data out of it. Do not start with HtmlDomParser::file_get_html($url) — that bypasses Laravel's HTTP layer (no timeouts, retries, headers, or Http::fake() in tests). Fetch first, parse second:

use Illuminate\Support\Facades\Http;
use voku\helper\HtmlDomParser;

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

$dom = HtmlDomParser::str_get_html($response->body());

Implementation Patterns

Selector-driven extraction into DTOs

Wrap parsing in a small dedicated class per source. Keep selectors in one place so a layout change is a one-file diff, and return typed data rather than leaking SimpleHtmlDom nodes through your app:

namespace App\Parsing;

use voku\helper\HtmlDomParser;

final class ProductPageParser
{
    public function parse(string $html): ParsedProduct
    {
        $dom = HtmlDomParser::str_get_html($html);

        return new ParsedProduct(
            title: $dom->findOne('h1.product-title')?->text() ?? '',
            price: $this->money($dom->findOne('span.price')?->text('0')),
            sku:   $dom->findOne('[data-sku]')?->getAttribute('data-sku'),
            images: $this->images($dom),
        );
    }

    private function images(HtmlDomParser $dom): array
    {
        $out = [];
        foreach ($dom->find('img.gallery') as $img) {
            if ($img->hasAttribute('src')) {
                $out[] = $img->getAttribute('src');
            }
        }

        return $out;
    }
}

Key API habits:

  • find($selector) returns a SimpleHtmlDomNode (iterable, array-access); find($selector, 0) returns a single node or null.
  • findOne($selector) returns a single node or null — always use the nullsafe operator.
  • findMulti($selector) is the explicit "give me all matches" form and reads better than the indexed overload.
  • $node->text() for inner text, $node->html() for inner HTML, $node->outerHtml() for the node itself.
  • $node->find('span') scopes the query to that node's subtree — use it instead of re-searching from the document root inside loops.

Turning find() results into Laravel collections

The returned collection is not a Illuminate\Support\Collection, but it is Traversable, so mapping is trivial:

$rows = collect($dom->find('table#stock tbody tr'))
    ->map(fn ($tr) => [
        'sku'   => $tr->findOne('td.sku')?->text(),
        'qty'   => (int) $tr->findOne('td.qty')?->text(0),
    ])
    ->filter(fn ($row) => filled($row['sku']))
    ->values();

Parse inside a queued job, cache the result

Parsing is CPU-bound and scraping is I/O-bound. Put both behind a job, and cache extracted data — not the HtmlDomParser object, which is not serializable and is memory-heavy:

final class RefreshProductSnapshot implements ShouldQueue
{
    public int $tries = 3;

    public function handle(ProductPageParser $parser): void
    {
        $html = Http::timeout(15)->retry(2, 500)->get($this->url)->body();
        $product = $parser->parse($html);

        Cache::put("product-snapshot:{$this->sku}", $product, now()->addHours(6));
    }
}

Store the raw HTML (Blade partial, storage/app/scrapes, or a html_snapshots table) when debugging selectors is likely — it lets you re-parse offline without refetching.

Mutation and re-serialization

The parser is bidirectional, which is useful for sanitizing user-edited HTML fields or rewriting links in stored email templates:

$dom = HtmlDomParser::str_get_html($userHtml);

foreach ($dom->find('a') as $a) {
    $href = $a->getAttribute('href');
    if ($href && ! str_starts_with($href, 'https://')) {
        $a->setAttribute('href', '#');
    }
}

foreach ($dom->find('script') as $script) {
    $script->outertext = ''; // effectively removes the node
}

$clean = $dom->html();

Testing

Because parsing is a pure string-in/data-out step, test the parser against fixture files committed under tests/Fixtures/ and reserve Http::fake() for the fetch layer:

Http::fake(['shop.test/*' => Http::response(file_get_contents(base_path('tests/Fixtures/product.html')))]);

$product = app(ProductPageParser::class)->parse(
    Http::get('https://shop.test/p/1')->body()
);

$this->assertSame('Widget', $product->title);

Bind parsers in AppServiceProvider (or rely on auto-wiring if they have no constructor dependencies) so jobs and controllers stay testable.

Gotchas and Tips

  • Empty results are null, not exceptions. findOne() and find($sel, 0) return null; find() returns an empty collection object. A silent selector drift will produce nulls and blank fields rather than errors — assert on parsed output in tests, and consider a rescue()/threshold check that fails the job when required fields are missing.

  • The CSS selector engine is not a browser engine. Treat it as simple selectors (tag, .class, #id, tag.class, [attr], [attr="value"], and tag[attr="value"]), not full CSS3. Combinators (>, +, ~), :nth-child, :not(), and comma-separated selector lists are unreliable across versions. When you need them, drop to XPath:

    $nodes = $dom->getDocument()->xpath("//div[contains(@class,'card')][2]//a/@href");
    

    and read ->item(0)->nodeValue, or re-feed the node's outerHtml() back into HtmlDomParser::str_get_html().

  • file_get_html() is a trap in Laravel. It uses PHP stream/curl wrappers directly: no Laravel middleware, no Http::fake() in tests, no retry/timeout conventions, and it throws exceptions that bypass your normal handling. Always fetch with Http/Guzzle and parse with str_get_html().

  • UTF-8 and entities are the classic failure mode. DOMDocument mangles non-ASCII without a charset hint. The fork does internal encoding repair, but if you see mojibake or é, normalize before parsing (e.g. mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')) and remember that text() may return HTML entities — run the result through html_entity_decode() before persisting or comparing.

  • Invalid HTML gets silently "fixed" by libxml. DOMDocument::loadHTML auto-closes tags, injects missing <tbody>, and drops stray nodes. Selectors against <table> markup often need tbody in the path. The fork suppresses libxml warnings; don't call libxml_use_internal_errors(false) around it.

  • Watch memory on large documents. The whole DOM is held in PHP objects, and magic properties like ->plaintext / ->href do work on access — don't access them in a tight loop if you can assign once. unset($dom) (and letting nodes go out of scope) after extraction matters in long-running queue workers; for batch jobs use $this->job->release()-style chunking or one parse per job.

  • Magic properties vs. methods. ->href, ->src, ->plaintext, ->innertext, ->outertext are convenient but inconsistent in the face of missing attributes (some return '' or null). Prefer getAttribute()/text()/html() in code you intend to keep, and treat the magic properties as quick scripting sugar.

  • Extension points: subclass HtmlDomParser/SimpleHtmlDom for domain-specific helpers (e.g. a ->priceAmount() accessor), or wrap the parser entirely behind an interface so you can swap to Symfony DomCrawler (better selector coverage), or PHP 8.4's native \Dom\HTMLDocument, without touching call sites. The fork also ships a set_callback() node hook inherited from the original project — verify it exists in your installed version before relying on it.

  • Don't use this for JavaScript-rendered pages. If the data isn't in the initial HTML, no amount of selector work will find it; you need a headless browser (Laravel Dusk, Browsershot, or an external rendering service). Also check robots.txt and the target's ToS before scheduling recurring scrapes, and set a real User-Agent.

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.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc