zendframework/zend-dom
Zend\Dom provides tools for parsing and querying HTML and XML documents, including CSS selector support translated to XPath via Zend\Dom\Query. A legacy Zend Framework component for DOM inspection and extraction.
zendframework/zend-dom is a small, framework-agnostic DOM querying component. In Laravel you will mostly use it as a convenience layer over DOMDocument/DOMXPath so you can query HTML with CSS selectors instead of hand-written XPath.
Important first step: the package is archived and abandoned. On a modern Laravel app (PHP 8+) Composer may refuse it or you may hit runtime incompatibilities. Prefer laminas/laminas-dom (same API, maintained namespace Laminas\Dom) or symfony/dom-crawler. If you are maintaining a legacy PHP 7 app and must use the Zend package:
composer require zendframework/zend-dom
Look first at:
vendor/zendframework/zend-dom/src/Query.php — the main entry point.vendor/zendframework/zend-dom/src/NodeList.php — what execute() returns.vendor/zendframework/zend-dom/src/Document.php and src/Document/Query.php — lower-level document/query helpers if you need them.Your first use case should be parsing a known HTML fragment and extracting nodes:
use Zend\Dom\Query;
$html = <<<'HTML'
<html><body>
<div class="product" data-id="42">
<h2>Widget</h2>
<a href="/products/42">Details</a>
</div>
</body></html>
HTML;
$dom = new Query($html);
foreach ($dom->execute('div.product') as $node) {
$link = $node->getElementsByTagName('a')->item(0);
dump([
'id' => $node->getAttribute('data-id'),
'title' => trim($node->getElementsByTagName('h2')->item(0)->textContent),
'url' => $link?->getAttribute('href'),
]);
}
That is the core workflow: construct Query with HTML, call execute() with a CSS selector (or XPath), iterate the resulting NodeList of DOMNode objects.
Do not sprinkle new Query(...) through controllers. Keep the dependency at the edge of your app so you can replace it later with Laminas or Symfony DomCrawler.
namespace App\Services;
use Zend\Dom\Query;
final class ProductPageParser
{
/**
* @return array<int, array{id: string, title: string, url: ?string}>
*/
public function parse(string $html): array
{
$dom = new Query($html, 'UTF-8');
$products = [];
foreach ($dom->execute('div.product') as $node) {
$titleNode = $node->getElementsByTagName('h2')->item(0);
$linkNode = $node->getElementsByTagName('a')->item(0);
if (! $titleNode) {
continue;
}
$products[] = [
'id' => $node->getAttribute('data-id'),
'title' => trim($titleNode->textContent),
'url' => $linkNode?->getAttribute('href'),
];
}
return $products;
}
}
Typical Laravel day-to-day usage is scraping a vendor page or parsing a third-party HTML response:
use Illuminate\Support\Facades\Http;
use App\Services\ProductPageParser;
$response = Http::timeout(10)->get($url)->throw();
$products = app(ProductPageParser::class)->parse($response->body());
If the source is slow or unreliable, put the fetch + parse in a queued job. Keep the parser synchronous and testable; let the job handle retries, timeouts, and persistence.
CSS selectors are the reason to use this package over raw DOMDocument:
$dom->execute('table.results tbody tr');
$dom->execute('a[rel="next"]');
$dom->execute('div.alert.error');
When CSS cannot express the relationship, use XPath directly. Query::execute() accepts XPath as well, typically detected by leading // or /:
$dom->execute('//div[contains(@class, "product")]/following-sibling::div[1]');
Keep a rule of thumb: CSS for structure and classes, XPath for text matching, sibling traversal, parent selection, or predicates.
NodeList into plain arrays immediatelyNodeList contains live DOM nodes. Do not pass it across layers or store it in a job payload. Normalize to arrays or DTOs as soon as possible:
$items = [];
foreach ($dom->execute('.item') as $node) {
$items[] = [
'text' => trim($node->textContent),
'href' => $node->getAttribute('href'),
];
}
getAttribute('href') returns exactly what is in the HTML. Resolve it with Laravel’s URL helpers or GuzzleHttp\Psr7\UriResolver:
use Illuminate\Support\Uri;
$href = $linkNode?->getAttribute('href');
$absolute = $href
? (string) Uri::of($baseUrl)->withPath($href) // or use url($href) for simple cases
: null;
Be careful with url() for protocol-relative and absolute URLs; use a proper URI resolver when scraping.
Keep fixture files in tests/Fixtures/*.html and assert the normalized output, not DOM internals:
$parser = new ProductPageParser();
$products = $parser->parse(file_get_contents(base_path('tests/Fixtures/products.html')));
$this->assertCount(3, $products);
$this->assertSame('Widget', $products[0]['title']);
This is also the best way to verify which CSS selectors the component actually supports before relying on them in production.
DOMDocument is sensitive to encoding. Normalize before handing HTML to Query:
private function normalizeHtml(string $html): string
{
if (! mb_check_encoding($html, 'UTF-8')) {
$html = mb_convert_encoding($html, 'UTF-8', 'ISO-8859-1');
}
// Helps DOMDocument interpret UTF-8 correctly when the source omits a charset meta tag.
return '<?xml encoding="UTF-8">' . $html;
}
Then construct new Query($this->normalizeHtml($html), 'UTF-8').
zendframework/zend-dom is abandoned and Composer will usually mark it as such. On Laravel 9/10/11 with PHP 8+, installation or runtime may fail because of old PHP constraints. For new work:
laminas/laminas-dom if you want the same Query/Document API under the Laminas\Dom namespace.symfony/dom-crawler if you want a more actively maintained CSS-selector crawler.DOMDocument + DOMXPath if you only need a handful of queries.If you must stay on Zend, pin PHP 7.x or maintain a fork, and document the migration path in the service wrapper.
The CSS-to-XPath conversion is basic. Do not assume every selector you use in CSS or jQuery works. Pseudo-selectors, some attribute operators, and complex combinators may be unsupported or behave differently. Test each selector against a fixture. When in doubt, fall back to XPath:
$dom->execute('//a[starts-with(@href, "/products/")]');
Real-world HTML is often broken. Wrap parsing with libxml error handling and restore global state:
$previous = libxml_use_internal_errors(true);
try {
$dom = new Query($html);
$nodes = $dom->execute('.item');
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
Do not let libxml warnings become Laravel exception noise unless you explicitly convert them.
DOMDocument will wrap fragmentsIf you pass a fragment such as <div>Hello</div>, DOMDocument will usually add html/body wrappers. Selectors still work, but saveHTML() output will not be byte-identical to the input. If fragment fidelity matters, use native DOMDocument::loadHTML with appropriate libxml flags or a different package.
textContent vs nodeValueFor element nodes, use textContent and trim() it. nodeValue can behave differently for attributes and text nodes. For links, read getAttribute('href'), not nodeValue.
NodeList is liveIf you remove or replace nodes while iterating, you can skip elements. Convert to an array first when mutating:
$nodes = iterator_to_array($dom->execute('.item'));
Symptoms: é instead of é, or empty text for non-ASCII content. Causes:
<meta charset="utf-8"> in the source.mb_convert_encoding.Prefer passing the encoding to Query and adding an XML encoding declaration or meta charset before parsing. Test with accented characters and emoji.
The package does not resolve URLs for you. If the page has a <base href="...">, read it and resolve against that rather than the request URL.
If you parse XML with namespaces, CSS selectors may not match as expected. Use XPath with registered prefixes via DOMXPath for namespaced documents, or strip/normalize namespaces before querying.
There is no service provider, facade, config file, or Blade directive. Treat it as a plain library. The only “config” is the encoding you pass and the HTML you feed it. Extension points are therefore:
Zend\Dom\Query if you need custom selector normalization.Zend\Dom\Document/Document\Query classes if you need more control than Query::execute().This is a querying component, not an HTML sanitizer. Parsing untrusted HTML for display or storage requires a dedicated sanitizer such as ezyang/htmlpurifier or Symfony’s HTML sanitizer. zend-dom will happily preserve dangerous markup in the DOM.
How can I help you explore Laravel packages today?