kub-at/php-simple-html-dom-parser
A fast, lightweight PHP HTML DOM parser that lets you find and extract elements from HTML using jQuery-like selectors. Compatible with simple_html_dom, it makes scraping, parsing, and manipulating markup easy with minimal code.
Install with Composer and start parsing — there is no service provider, no config file, and nothing to publish. That is the entire setup:
composer require kub-at/php-simple-html-dom-parser
Use the namespaced facade rather than the global str_get_html() / file_get_html() functions, so you stay autoload-safe and greppable:
use KubAT\PhpSimple\HtmlDomParser;
$dom = HtmlDomParser::str_get_html('<div class="price">$19.99</div>');
$price = $dom->find('div.price', 0)->plaintext; // "$19.99"
$dom->clear();
The first thing to read in the vendor directory is the bundled upstream manual shipped inside the package source (src/simplehtmldom/manual/) — it is the only real documentation for the selector syntax and the simple_html_dom_node API, and it is more current than most blog posts about this library. Skim the "quick start" and "find" sections and you have 90% of what you need.
Your first realistic use case in a Laravel app is not scraping a whole site — it is extracting a few fields from an HTML blob you already have (an emailed invoice, a webhook payload, a legacy HTML report, a scraped product page). Wire it into a throwaway artisan command or a test first:
$dom = HtmlDomParser::str_get_html($html);
$title = $dom->find('h1', 0)?->plaintext;
Because the package returns a mutable, memory-heavy simple_html_dom tree rather than a query builder, decide early where in your codebase the "parse and immediately map to plain PHP arrays/DTOs" boundary lives — usually a single app/Support/Html/ service class. Everything downstream should never see a simple_html_dom object.
Always fetch with Laravel's HTTP client, then parse. HtmlDomParser::file_get_html() exists, but it is file_get_contents() in a trench coat: no timeouts you control, no status-code handling, no retries, and it fails outright if allow_url_fopen is off. Treat the library as a pure parser:
use Illuminate\Support\Facades\Http;
use KubAT\PhpSimple\HtmlDomParser;
$response = Http::withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; MyApp/1.0)'])
->timeout(15)
->retry(3, 200)
->get($url)
->throw();
$dom = HtmlDomParser::str_get_html($response->body());
Wrap it in a small injectable service so you can mock it, swap it later for symfony/dom-crawler, and keep clear() calls in one place:
namespace App\Support\Html;
use KubAT\PhpSimple\HtmlDomParser;
class HtmlParser
{
public function parse(string $html): \simple_html_dom
{
return HtmlDomParser::str_get_html(
$html,
lowercase: true,
forceTagsClosed: false, // don't mangle HTML5 void elements
stripRN: false, // preserve newlines inside <pre>
);
}
/**
* @return array<int, array<string, string|null>>
*/
public function rows(string $html, string $rowSelector, array $fields): array
{
$dom = $this->parse($html);
try {
return collect($dom->find($rowSelector))
->map(fn ($row) => collect($fields)
->mapWithKeys(fn ($selector, $key) => [
$key => $this->text($row->find($selector, 0)),
])
->all())
->all();
} finally {
$dom->clear();
unset($dom);
}
}
private function text(?\simple_html_dom_node $node): ?string
{
return $node ? trim(preg_replace('/\s+/u', ' ', $node->plaintext)) : null;
}
}
Extraction cheatsheet. find() takes a CSS-subset selector and an optional index (0 = first match, returns null if absent). It returns a plain array, so collect() composes naturally:
$dom->find('a[href^="/products/"]'); // attribute prefix
$dom->find('img[data-src]'); // attribute presence
$dom->find('div.card > h2.title'); // child combinator
$dom->find('table#prices tbody tr'); // id + descendant
$node->attr['href'] ?? null; // attribute access (preferred)
$node->plaintext; // visible text
$node->innertext; // inner HTML
$node->outertext; // node's own HTML
$node->children(); // array of nodes
$node->parent(); $node->next_sibling(); // tree navigation
Map to DTOs/collections at the boundary rather than passing nodes around. A queued job that returns collect($dom->find('.product'))->map(fn ($n) => ProductData::fromNode($n)) is testable, serialisable, and immune to the tree being cleared.
DOM rewriting is a genuine use case. Because innertext and outertext are writable, you can sanitise or rewrite stored HTML — strip <script>, rewrite relative links to absolute, remove tracking pixels:
$dom = HtmlDomParser::str_get_html($storedHtml);
foreach ($dom->find('script, iframe') as $node) {
$node->outertext = '';
}
foreach ($dom->find('a[href]') as $a) {
if (str_starts_with($a->href, '/')) {
$a->href = url($a->href);
}
}
$clean = $dom->save();
$dom->clear();
For anything queued or bulk, use Bus::batch() with a per-host RateLimiter, cache the parsed arrays (Cache::remember("scrape:{$url}", now()->addHours(6), ...)) and never hold multiple simple_html_dom objects alive at once — see the memory note below.
Testing is straightforward because parsing is pure: Http::fake() the response, point it at a fixture in tests/Fixtures/*.html, and assert on the mapped arrays. No network, no flakiness, no mocking of the parser itself.
artisan loops) you must call $dom->clear() in a finally block and unset() the variable. Without it a Horizon worker will balloon over a few hundred documents and get killed. Consider a dedicated queue with --max-jobs set low.stripRN defaults to true and silently removes \r/\n. That is fine for prose but destroys <pre>, <textarea>, and code blocks. Pass stripRN: false when the source is code or whitespace-sensitive.forceTagsClosed defaults to true and will rewrite HTML5 void elements (<br>, <img>, <meta>) in ways that occasionally break round-tripping. Set it to false for HTML5 input; leave it true for tag-soup legacy HTML where you actually want it repaired.#id, .class, attribute operators (=, !=, *=, ^=, $=) and the space/> combinators work. Pseudo-classes (:nth-child, :not, :first-of-type) and grouped selectors (div, span) generally do not — run two find() calls and merge instead. There is no ->text() helper; use ->plaintext.file_get_html() is a trap in production. It ignores HTTP status codes (you get the 404 page and parse it happily), has no meaningful timeout, sends a default user-agent that many sites block, and dies if allow_url_fopen=0. Also never pass user-supplied URLs to it — that is a textbook SSRF. Use Http::get() and str_get_html().$node->foo is ambiguous. Magic __get returns the attribute foo if present, otherwise a child element named <foo>. $node->title on a <div title="x"><title>y</title></div> will surprise you. Prefer $node->attr['title'] for attributes and $node->find('title', 0) for children.mb_convert_encoding($html, 'UTF-8', $charset) before parsing; the $target_charset parameter will not rescue a mis-decoded source. Also run html_entity_decode(..., ENT_QUOTES, 'UTF-8') on plaintext if you need /— resolved — the built-in entity table is incomplete.voku/simple_html_dom fork, which shares the same API shape and adds better CSS selector support plus PHP 8 fixes.HtmlParser interface so migrating to symfony/dom-crawler + symfony/css-selector (the better long-term choice for anything resembling a crawler) is a one-file change.composer.json.How can I help you explore Laravel packages today?