wikimedia/zest-jq
A jQuery-style DOM query and manipulation layer for PHP, built on the Zest CSS selector engine. Use familiar chainable selectors to find, filter, and work with HTML nodes in server-side code.
wikimedia/zest-jq is a thin, jQuery-flavoured fluent wrapper around the Zest CSS selector engine (wikimedia/zest-css), which is the selector library MediaWiki uses internally. It lets you load an HTML fragment/document into a DOM and then query it with CSS selectors using chainable methods instead of hand-writing DOMXPath expressions.
Install
composer require wikimedia/zest-jq
Requirements to check before you commit: PHP 8.x and ext-dom (verify the constraint in the package's composer.json — this is a small Wikimedia library, not a framework component, so the supported range moves with MediaWiki's own PHP support matrix).
Look here first
https://gerrit.wikimedia.org/g/mediawiki/libs/ZestJQ) — it is the only authoritative API reference. The GitHub repo is a read-only mirror; issues and patches belong in Gerrit, so don't open PRs there.vendor/wikimedia/zest-jq/src/ — the surface area is small, so reading the main class end-to-end takes minutes and is faster than guessing at jQuery parity.wikimedia/zest-css docs for the exact CSS selector subset supported.First use case
The natural day-one job is pulling structured data out of an HTML blob you don't control — a scraped page, an email body, a legacy CMS export, an RSS item's content:encoded — where you want "all the links inside .article-body" without a full crawler dependency.
use ZestJQ\ZestJQ;
$jq = new ZestJQ($html); // wrap an HTML string (confirm exact constructor in the README)
$links = $jq->find('.article-body a')
->map(fn ($node) => [
'href' => $node->getAttribute('href'),
'text' => trim($node->textContent),
])
->toArray();
If that returns what you expect, you have everything you need for 90% of real usage.
Wrap once, query many times. Parsing is the expensive part, not selecting. Load the document into a single ZestJQ instance per request/job and run every selector against it, rather than constructing a new instance per row or per field.
$jq = new ZestJQ($response->body());
$title = $jq->find('h1.title')->first()->text();
$prices = $jq->find('.price')->map(fn ($n) => (float) trim($n->textContent))->toArray();
$authors = $jq->find('.byline .author')->map(fn ($n) => trim($n->textContent))->toArray();
Wrap an existing DOM node, not the whole page. If you already have a DOMDocument / DOMElement (e.g. from DOMDocument::loadHTML or symfony/dom-crawler), scope the query to that subtree. This is how you interoperate with an existing crawl pipeline instead of replacing it.
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML($html);
$jq = new ZestJQ($doc); // or the specific DOMElement you care about
$jq->find('table tr')->each(function ($row) { /* ... */ });
Put it behind an extraction service. Keep selector logic out of controllers and jobs. A small class per source ("SiteXExtractor") that takes an HTML string and returns a typed DTO/array is the pattern that survives contact with reality:
final class ArticleExtractor
{
public function extract(string $html): ArticleData
{
$jq = new ZestJQ($html);
return new ArticleData(
title: trim($jq->find('h1')->first()->text()),
body: trim($jq->find('.article-body')->first()->text()),
tags: $jq->find('.tags a')->map(fn ($n) => trim($n->textContent))->toArray(),
);
}
}
Bind it in a service provider (or just let Laravel autowire it) and inject it into queued jobs.
Pipeline it with the HTTP client.
$html = Http::withHeaders(['User-Agent' => config('scraper.ua')])
->timeout(15)
->get($url)
->throw()
->body();
$data = app(ArticleExtractor::class)->extract($html);
Do the fetch and the parse in the same queued job so retries are atomic, and rate-limit with RateLimited middleware rather than sleep().
Use each() for side effects, map() for transformation. each() is your jQuery .each() analogue for accumulating into an external array or touching nodes; map()/toArray() is for producing a value. Be explicit about which you're doing — mixing them is where these chains become unreadable.
Testing. Selector extraction is pure and deterministic: feed fixture HTML from tests/Fixtures/*.html and assert on the returned DTO. Store one fixture per real-world page shape you support, including the broken ones. That fixture set is your regression suite when a source changes its markup.
mediawiki/libs/ZestJQ), and contributing means git review. Treat the GitHub README as possibly stale relative to HEAD.composer.json, keep all usage behind your own ArticleExtractor-style boundary, and be ready to swap to symfony/dom-crawler (CssSelector component) or masterminds/html5 if the package stalls. The swap is cheap if you never let ZestJQ types leak into your domain code.<script>, onerror=, or javascript: URLs. Use mews/purifier / HTMLPurifier for that, and never {!! !!} the output of ->html().DOMDocument. The classic trap: loadHTML() assumes ISO-8859-1 when there's no <meta charset>, so accented text and emoji come back as mojibake. Prefix the source with <?xml encoding="UTF-8"> and strip it, or mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') (deprecated in PHP 8.2 — prefer the meta-prefix or mb_encode_numericentity). Also call libxml_use_internal_errors(true) or malformed markup will spray warnings into your logs.<html><body>. $doc->saveHTML() on the whole document returns the synthetic wrapper. Serialize the specific node ($doc->saveHTML($node)) or normalize in your extractor.:contains(), :visible, :eq(), :first as a pseudo-selector, and friends are not available — use ->first(), ->eq(n), ->filter(), ->text() instead. :nth-child, attribute selectors, and combinators are the safe ground. If a selector silently returns nothing, suspect the pseudo-class before you suspect your HTML.->find('.nope')->first()->text() on a missing element is a common source of null-property or empty-string surprises. Guard with ->length / ->count() (per the README) before dereferencing, and fail loudly in your extractor if a required node is absent — silent empty strings are how scrapers rot.new ZestJQ($rowHtml) inside a 5,000-iteration loop. Parse once, then find() per row against an already-parsed subtree.ZestJQ instances in the container or cache driver. They hold live DOMDocument graphs; serialize your extracted arrays/DTOs, never the wrapper.wikimedia/zest-css. If you need a selector Zest doesn't support, that's an upstream conversation in Gerrit — plan a workaround (extra find() + filter()) rather than patching vendor/.robots.txt/ToS, set a real User-Agent, cap concurrency, and cache aggressively.How can I help you explore Laravel packages today?