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+.
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/:
HtmlDomParser.php — static factory (str_get_html, file_get_html, str_get_html_from_file) and global helpers.SimpleHtmlDom.php — the single-node object: find(), findOne(), findMulti(), text(), html(), outerHtml(), attribute access, and the magic properties ($el->href, $el->src, $el->plaintext).SimpleHtmlDomNode.php — the array-like collection returned by find().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());
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.find() results into Laravel collectionsThe 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();
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.
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();
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.
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.
How can I help you explore Laravel packages today?