Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Zest Jq Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The README on the Gerrit-hosted repo (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.
  2. 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.
  3. The underlying 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.

Implementation Patterns

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.

Gotchas and Tips

  • The GitHub repo is a mirror. It is read-only, has almost no stars, and issues filed there go nowhere. The real source of truth is Gerrit (mediawiki/libs/ZestJQ), and contributing means git review. Treat the GitHub README as possibly stale relative to HEAD.
  • Very low adoption — treat it as a vendored utility, not a strategic dependency. With effectively no dependents, you are the only person who will notice a breaking change. Pin the exact version in 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.
  • It is not a sanitizer. This is a selector engine. Do not use it to clean user-submitted HTML before rendering — it will not strip <script>, onerror=, or javascript: URLs. Use mews/purifier / HTMLPurifier for that, and never {!! !!} the output of ->html().
  • UTF-8 mangling with 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.
  • Fragments get wrapped in <html><body>. $doc->saveHTML() on the whole document returns the synthetic wrapper. Serialize the specific node ($doc->saveHTML($node)) or normalize in your extractor.
  • CSS subset, not jQuery. Zest implements CSS selectors, not jQuery's extensions. :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.
  • Empty results are quiet, not loud. ->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.
  • Reuse the parsed document inside loops. The most common performance bug is constructing new ZestJQ($rowHtml) inside a 5,000-iteration loop. Parse once, then find() per row against an already-parsed subtree.
  • Don't cache ZestJQ instances in the container or cache driver. They hold live DOMDocument graphs; serialize your extracted arrays/DTOs, never the wrapper.
  • Extension points are limited. You can subclass/wrap the wrapper, but the underlying selector behaviour lives in 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/.
  • Respect the sources. Selector-engine convenience makes scraping feel free; it isn't. Check robots.txt/ToS, set a real User-Agent, cap concurrency, and cache aggressively.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle