sunra/php-simple-html-dom-parser
A maintained PHP port of Simple HTML DOM for loading and manipulating HTML with jQuery-like CSS selectors. It provides an easy, forgiving API for scraping pages and extracting or editing elements, with Composer support for modern PHP versions.
Install and confirm what you got. composer require sunra/php-simple-html-dom-parser pulls down exactly one meaningful file: vendor/sunra/php-simple-html-dom-parser/Src/Sunra/PhpSimple/HtmlDomParser.php. That class is a thin, namespaced façade over the original 2011-era simple_html_dom / simple_html_dom_node classes, which are still declared in the global namespace. Read that file first — it is short, and it is the entire API surface you are buying.
The two entry points. Everything starts with a static call that returns either an HtmlDomParser (a simple_html_dom subclass) or null on failure:
use Sunra\PhpSimple\HtmlDomParser;
$dom = HtmlDomParser::str_get_html($htmlString); // parse a string you already have
$dom = HtmlDomParser::file_get_html($urlOrPath); // fetch + parse in one go
From there the only verb you need is find(), which returns an array of simple_html_dom_node (or a single node if you pass an index):
$dom = HtmlDomParser::str_get_html('<ul><li><a href="/a">A</a></li><li><a href="/b">B</a></li></ul>');
foreach ($dom->find('li a') as $a) {
echo $a->href . ' => ' . trim($a->plaintext) . PHP_EOL;
}
First real use case. The natural first task is "pull a list of records off one page": fetch a listing, iterate rows, map each row to an array, done. Start there rather than with a full crawler — the library has no HTTP client, no queueing, no retry logic, and no CSS3 selector engine, so it is strictly an HTML-to-tree tool. If your very first task is crawling a site with pagination and cookies, reach for Guzzle plus this parser, not file_get_html().
Check your PHP version before you commit. The last release is from November 2016 and the underlying code predates PHP 7. It runs on modern PHP, but on PHP 8.2+ you will see a wall of Creation of dynamic property simple_html_dom_node::$... is deprecated notices. Confirm your error reporting settings tolerate that, or budget for migration (see Gotchas).
Separate fetching from parsing. This is the single most valuable pattern. Treat HtmlDomParser as a pure function string -> tree, and let Laravel's HTTP client (or Guzzle) own the network:
$response = Http::withHeaders(['User-Agent' => config('scraper.ua')])
->timeout(15)
->get($url);
if (! $response->successful()) {
throw new RuntimeException("Fetch failed: {$response->status()}");
}
$dom = HtmlDomParser::str_get_html($response->body());
You gain timeouts, retries, redirect control, cookies, proxy support, and — crucially — testability, because your parser can now be fed fixtures with no network at all.
Wrap it behind your own interface. Given the package's age, assume you will swap it out. One small class isolates the blast radius:
interface HtmlDocumentFactory
{
public function fromString(string $html): HtmlDocument;
}
Implement it with HtmlDomParser, and have your extraction code depend on your HtmlDocument wrapper rather than on simple_html_dom_node. When you migrate to symfony/dom-crawler or voku/simple_html_dom, one binding changes, not forty scrapers. Bind the interface in a service provider so it is injectable and mockable in tests.
Map nodes to DTOs immediately. Do not let simple_html_dom_node objects travel through your application. Convert at the boundary:
$listings = collect($dom->find('article.listing'))->map(fn ($row) => new ListingDto(
title: trim($row->find('h2', 0)?->plaintext ?? ''),
url: $row->find('a.title', 0)?->href ?? '',
price: (float) preg_replace('/[^0-9.]/', '', $row->find('.price', 0)?->plaintext ?? '0'),
))->filter(fn (ListingDto $l) => $l->title !== '' && $l->url !== '');
Note the find(..., 0) returning null for a miss — always null-coalesce, because a layout change on the target site should degrade a field, not throw.
Loop with explicit cleanup. This is the pattern that separates working scrapers from ones that die at item 4,000. Inside any loop over nodes, detach and free:
foreach ($dom->find('tr') as $row) {
// ...extract...
$row->clear();
}
unset($row);
// when fully done
$dom->clear();
unset($dom);
The tree is a graph of parent/child references; PHP's refcounter will not reclaim it promptly, and clear() alone historically leaves cycles behind. In long-running queue workers, call gc_collect_cycles() after a batch, or run each parse in its own job so the process exits.
Iterate tables via children, not nested selectors. find('table tr td') re-walks the whole tree for each call. For repeated extraction from a known structure, descend explicitly:
foreach ($dom->find('table.results tbody tr') as $tr) {
$cells = $tr->children();
$name = trim($cells[1]->plaintext);
}
Test with saved fixtures. Store a handful of real HTML snapshots under tests/fixtures/ and assert your DTO mapping against them. Because the parser is lenient and never throws on malformed markup, fixture tests are the only reliable way to catch the moment a site's markup drifts.
Queue the work in Laravel. Scraping belongs in a job with a rate limiter, not in a controller. The job fetches, parses, upserts to Eloquent, and clears the DOM before returning. Keep the DOM lifetime inside the job so a memory-hungry page cannot poison a long-lived worker.
The package is effectively abandoned. No release since 2016, no PHP 8 declarations, no security process. Pin the exact version in composer.json, and treat "replace this" as planned technical debt. The drop-in successors are voku/simple_html_dom (a maintained fork of the same API, best for cheap migration) and symfony/dom-crawler (better selectors, better memory, actively maintained).
MAX_FILE_SIZE silently truncates your input. The library defines a 600 KB ceiling and any longer source string is chopped before parsing — no exception, just a tree that mysteriously stops halfway. If you routinely parse large pages, define the constant before the class loads (bootstrap / pre-autoload) or chunk the fetch.
Global class collisions. simple_html_dom and simple_html_dom_node are declared globally. If any other dependency bundles the original parser, you get a fatal "cannot redeclare class". Audit composer why when you see that error; there is no namespace escape hatch in this package.
find() semantics are not CSS3. Supported: tag, #id, .class, descendant (space), child (>), and attribute selectors including [a], [a=v], [a!=v], [a^=v], [a$=v], [a*=v], plus comma-separated selector lists. Not supported: pseudo-classes (:nth-child, :not, :has), sibling combinators, ~, or attribute case-insensitivity flags. If a selector silently returns [], it is almost always an unsupported pseudo-class rather than a wrong path — verify in a scratch script before restructuring your extraction code.
find() searches descendants only. Calling $node->find('a') will not match $node itself if it happens to be an <a>. And find($sel, $idx) returns the node, null on miss, so the index variant is the safe accessor.
Everything is lowercased by default. Tag and attribute names are normalised, which mangles viewBox, data-X, and XML-ish markup. The third find() argument toggles lowercase behaviour; attribute values are preserved, so href and src are safe.
Encoding is your job. The parser guesses charset and can mangle UTF-8 or Windows-1252 pages (mojibake ’ instead of ’). Detect from the Content-Type header or <meta charset>, mb_convert_encoding() to UTF-8 first, then hand the string to str_get_html(). Also run extracted text through html_entity_decode(trim($node->plaintext), ENT_QUOTES | ENT_HTML5, 'UTF-8') — entities survive parsing.
Missing attributes return null, not empty string. $node->href on an anchor without href yields null; concatenating it works but strlen() warnings appear. Guard with ?? ''.
Never use file_get_html() in production. It is file_get_contents() under the hood: no timeout, no headers, no status-code check, and it respects allow_url_fopen. A hanging remote host will hang your request. Restrict it to throwaway scripts.
Debug by dumping HTML, not by staring at selectors. When extraction fails, echo $dom->find('body', 0)->innertext; (or ->outertext) to see exactly what the parser built — malformed source produces nested-tag repairs that differ from what Chrome shows you. Comparing the parser's tree against browser DevTools is the fastest diagnosis for "the selector is right but it returns nothing."
Extension point. HtmlDomParser is a plain class with static methods and a protected __construct; if you need extra helpers (e.g. ->text() with normalised whitespace, or ->attrOrNull()), subclass it and add your own factory statics returning your subclass. That keeps simple_html_dom_node untouched while giving your codebase a sane, swappable surface.
How can I help you explore Laravel packages today?