erivello/simple-html-dom-bundle
Symfony2 bundle that integrates the Simple HTML DOM Parser, letting you load HTML and find tags with jQuery-like selectors via the simple_html_dom service. Extract links, content, and attributes easily.
Install (Symfony 2.x only)
composer require erivello/simple-html-dom-bundle:dev-master
// app/AppKernel.php
public function registerBundles()
{
$bundles = array(
// ...
new Erivello\SimpleHtmlDomBundle\ErivelloSimpleHtmlDomBundle(),
);
}
There is no configuration tree, no Flex recipe, and no services.yaml snippet. Registration in AppKernel is the entire setup.
Where to look first
vendor/erivello/simple-html-dom-bundle/ — read the bundle's Resources/config/services.xml (or equivalent). It is a few lines; it just aliases the global simple_html_dom class to the container id simple_html_dom. Everything else you'll need lives in the parser, not the bundle.vendor/erivello/simple-html-dom-bundle/.../simplehtmldom/ (or wherever the bundled simple_html_dom.php sits) — this is the real API surface: load(), find(), ->plaintext, ->innertext, ->outertext, ->getAttribute(), children(), parent(), clear().First use case — pull links off a page
// inside a Symfony 2.x controller extending the base Controller
$parser = $this->container->get('simple_html_dom');
$parser->load('http://example.com/');
foreach ($parser->find('a') as $element) {
echo $element->href . PHP_EOL;
}
$parser->clear(); // free the node tree before the next load
That is the whole loop of value: fetch, find(), read a property. If your target is Symfony 3.4+ or 4+, the service is private (or AppKernel doesn't exist) — see Gotchas before you spend time on the documented path.
Wrap it in your own service — do this on day one
The container hands back the concrete simple_html_dom instance, which is global-namespaced and unmockable. Inject it into an application-owned class so call sites depend on your interface, not the parser:
namespace AppBundle\Html;
use simple_html_dom;
class HtmlExtractor
{
private $parser;
public function __construct(simple_html_dom $parser)
{
$this->parser = $parser;
}
/**
* @return array<array{href:string,text:string}>
*/
public function links(string $html): array
{
$this->parser->clear();
if (!$this->parser->load($html)) {
return [];
}
$out = [];
foreach ($this->parser->find('a') as $a) {
$out[] = [
'href' => (string) $a->href,
'text' => trim($a->plaintext),
];
}
$this->parser->clear();
return $out;
}
}
# app/config/services.yml (Symfony 3.4 style)
services:
simple_html_dom:
class: simple_html_dom
public: true
app.html_extractor:
class: AppBundle\Html\HtmlExtractor
arguments: ['@simple_html_dom']
Now the swap to symfony/dom-crawler later is one file, and your tests mock HtmlExtractor instead of the parser.
Separate fetching from parsing
load($url) does network I/O inside file_get_contents with no timeout, redirect, or proxy control. In production paths, fetch yourself and hand over a string:
$html = $httpClient->request('GET', $url, ['timeout' => 5])->getContent();
$extractor->links($html);
This gives you retries, throttling, auth headers, host allow-lists, and a size cap — none of which the parser offers. It also makes the parse step trivially testable from fixtures.
Selector recipes that actually work
$parser->find('table tr'); // descendant combinator: fine
$parser->find('div#main'); // id
$parser->find('div.item'); // class
$parser->find('a[href]'); // attribute presence
$parser->find('a[href^=http]'); // attribute prefix
$parser->find('a', 0); // first match, or null
$row = $parser->find('table tr', 3); // nth match, or null
Scope queries by calling find() on a node instead of the root — much faster than re-scanning the document for each field:
foreach ($parser->find('div.product') as $product) {
$name = $product->find('h2', 0)->plaintext;
$price = $product->find('span.price', 0)->plaintext;
$link = $product->find('a', 0)->href;
}
Batch/console work
For a crawler command, keep one parser instance, call clear() between documents, and bound input size:
protected function execute(InputInterface $input, OutputInterface $output)
{
$parser = $this->getContainer()->get('simple_html_dom');
foreach ($urls as $url) {
$html = @file_get_contents($url);
if ($html === false || strlen($html) > 5_000_000) {
continue; // skip, don't let one bad page kill the run
}
$parser->clear();
$parser->load($html);
// ... extract ...
$parser->clear();
unset($html);
}
}
Run those commands with an explicit -d memory_limit=512M and watch RSS; the parser is memory-bound, not CPU-bound.
Fixture-driven tests
Save a handful of real pages under Tests/Fixtures/*.html and feed them as strings. Never let the test suite call load('http://...') — you get flaky builds, and on CI without network access, silent empty results that look like passing tests.
$html = file_get_contents(__DIR__.'/Fixtures/listing.html');
$this->assertCount(12, $extractor->links($html));
Mapping to your domain early
Extract straight into arrays or DTOs at the boundary and return those. The simple_html_dom_node objects hold references into the document tree; returning them from services leaks parser internals into controllers, templates, and serializers, and keeps the whole tree alive.
Reset is a pattern, not an afterthought
The service is shared. load() on a dirty parser, or forgetting clear(), is the single most common source of "why does my second page have the first page's links." Make clear() the first and last line of every public method on your wrapper.
The service is a singleton with state
$this->container->get('simple_html_dom') returns the same instance every time within a request. Loading document B does not necessarily discard everything from document A cleanly; clear() first. Under persistent runtimes (RoadRunner, Swoole, FrankenPHP) or long-lived workers, treat that instance as a per-request leak unless you reset it explicitly.
Symfony 3.4+/4+ breaks the documented install
public: true on the definition (as in the snippet above) or, better, inject the parser into your wrapper and never touch the container.AppKernel, no app/config. You will hand-write the service definition and bundle registration doesn't apply. At that point you are maintaining the wiring yourself — which is the argument for skipping the bundle and using symfony/dom-crawler directly.simple_html_dom; the class has no namespace and isn't a bundle type. Alias it manually if you use it.Silent failure everywhere
load() and file_get_html() return false on failure; find() returns an empty array when nothing matches; find('a', 0) returns null. None of these throw. Bad selectors, blocked requests, and parse failures all look identical: missing data. Assert on result counts at the boundary of your wrapper and throw a domain exception there.
load('http://...') also fails silently if allow_url_fopen is off, or if default_socket_timeout expires. Check php -i | grep allow_url_fopen before debugging selectors that were never the problem.
Memory lifecycle
The parser builds a full node tree and keeps it; large or deeply-nested documents blow up. Mitigations:
$parser->clear() after each document; unset() node references you kept.$node->find() over repeated root-level find() — cheaper and less likely to hold extra references.simple_html_dom_node objects in arrays that outlive the parse; convert to scalars immediately.The selector engine is not jQuery and not CSS3
Supported reliably: descendant combinator, tag, .class, #id, [attr], [attr=value], and a few attribute operators (^=, *=, ~=, !=). Weak or unreliable: child combinator >, sibling combinators, :nth-child, :not(), :has(), namespaced tags. Pseudo-classes that do exist are inconsistent across versions. If you need real selector semantics, that's the point at which to move to CssSelector + DomCrawler.
Tag and attribute names are lowercased; attribute values are case-sensitive. <A HREF=...> works, [href=HTTP://...] may not.
Property access quirks
$el->href returns the attribute value or an empty string — it doesn't error, so typos like $el->hreff fail silently. $el->getAttribute('href') / $el->hasAttribute('href') are more explicit.plaintext is text content; innertext is inner HTML; outertext is the element with its own tag. outertext is the fastest way to debug what a selector actually matched.plaintext is not entity-decoded. Run html_entity_decode($text, ENT_QUOTES, 'UTF-8') before storing, and normalize whitespace with preg_replace('/\s+/u', ' ', $text).->outertext mutates the tree; treat nodes as read-only unless you actually need the editing feature.Encoding
The parser predates HTML5 and charset sniffing is basic. If you see mojibake, convert before loading:
$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
$parser->load($html);
and force mb_internal_encoding('UTF-8') in your command/bootstrap.
PHP 8
The bundled simplehtmldom is SourceForge-era code: dynamic property creation, loose comparisons, and other deprecations. Expect a wall of notices and possibly fatals. Mitigate by pinning the composer constraint to a commit hash, vendoring the parser into your repo, and running the suite with error_reporting(E_ALL & ~E_DEPRECATED) only in dev — don't ship a suppression that hides real problems. Test explicitly; do not assume it works.
dev-master is a live wire
There are no tagged releases. composer update can silently change parsing behavior. Pin by commit in composer.lock, or fork the bundle and the parser, and treat upstream commits as something you merge intentionally.
SSRF and input trust
Any code path where a user-supplied URL reaches load() / file_get_html() is an SSRF vector — the parser will happily fetch http://169.254.169.254/, internal hostnames, and file:// in some configurations. Never pass untrusted URLs. Fetch with symfony/http-client or Guzzle inside a host allow-list with a timeout and response size cap, then pass the string to load().
Debugging workflow
$parser->load($html);
var_dump(count($parser->find('div.product'))); // did the selector match anything?
echo $parser->find('body', 0)->outertext; // what did it actually parse?
If count() is 0 for body, the document didn't load — go back to encoding and fetch. If body matches but your selector doesn't, it's a selector dialect problem, not a parsing problem. Those two are the only branches; separate them first.
Extension points are minimal
There's no compiler pass and no tag. To customize, either decorate your own wrapper, or override the simple_html_dom service definition with a subclass of the parser (useful for adding logging or a fetch guard). Anything more elaborate means you've outgrown the bundle.
How can I help you explore Laravel packages today?