duzun/hquery
hQuery is a fast, lightweight PHP HTML/XML parser with a jQuery-like API. It supports CSS selectors and DOM traversal, has no external dependencies, and handles broken or incomplete HTML gracefully, making web scraping, content extraction, and HTML manipulation simple and memory-efficient.
Install with Composer and you're done — no extensions, no PECL, no cURL requirement:
composer require duzun/hquery
The entry points you'll reach for first are the static factories:
hQuery::fromHTML($html) — parse a string you already fetched (best for tests and queued jobs)hQuery::fromUrl($url, $options) — fetch + parse in one call (uses file_get_contents under the hood)hQuery::fromFile($path) — parse a local file (useful for cached crawls or fixtures)Your first use case: pull <h1> and link targets off a page.
use duzun\hQuery;
$doc = hQuery::fromHTML($html);
$title = $doc->find('h1')->text();
$links = $doc->find('a')->attr('href'); // array of hrefs
Then iterate nodes when you need structure rather than just values:
foreach ($doc->find('article.post') as $post) {
$row = [
'title' => $post->find('h2')->text(),
'url' => $post->find('a.more')->attr('href'),
'body' => $post->find('.excerpt')->html(),
];
}
Read the README's selector list first — hQuery supports CSS2 + most of CSS3 (#id, .class, [attr^="…"], :nth-of-type(), :not(), combinators), but it is explicitly not a browser engine.
Separate fetching from parsing. hQuery::fromUrl() is convenient but it's file_get_contents — no retries, no cookie jar, no Guzzle middleware. In Laravel you almost always want to fetch with the Http facade or Guzzle and hand the body to hQuery::fromHTML():
$html = Http::withHeaders(['User-Agent' => config('scraper.ua')])
->timeout(15)
->get($url)
->throw()
->body();
$doc = hQuery::fromHTML($html, $url); // second arg = base URL for relative links
Wrap parsing in a dedicated service or DTO. Scrapers rot; keeping selectors in one class per site makes maintenance cheap.
final class ProductPageParser
{
public function parse(string $html): ProductData
{
$doc = hQuery::fromHTML($html);
return new ProductData(
title: trim($doc->find('h1.product-title')->text()),
price: (float) preg_replace('/[^\d.]/', '', $doc->find('.price')->text()),
images: $doc->find('img.gallery')->attr('src'),
);
}
}
Cache aggressively. Site HTML rarely changes second-to-second; Laravel's cache is a natural fit:
$html = Cache::remember("html:$url", now()->addHours(6), fn () => Http::get($url)->body());
$doc = hQuery::fromHTML($html);
Queue it with rate limiting. Scraping belongs in a ShouldQueue job so you can use RateLimiter::attempt(), Bus::batch(), and retryUntil(). Keep one job per URL (or pagination page) and persist parsed DTOs to the DB rather than the raw tree.
Pagination loop. Scrapers commonly need ?page=N — walk $doc->find('a.next')->attr('href') (or ->attr('href')[0]) until empty, with a hard cap to avoid infinite loops.
Tree traversal over regex. Use ->parent(), ->children(), ->next(), ->prev(), ->closest() to move around; it's far more robust than string munging and faster than DOMDocument for multi-megabyte inputs.
Testing. Fixture HTML + hQuery::fromHTML(file_get_contents(base_path('tests/fixtures/product.html'))) is a clean unit test with zero network. Pair with Http::fake() for integration coverage.
attr() and text() return arrays on collections. When you find() a set, ->attr('href') gives you ['/a', '/b']; ->text() behaves similarly. Indexing (->attr('href')[0]) or iterating nodes is the safe move. On a Node (obtained via foreach or ->get(0)) these return scalars.attr('href') does not resolve ../foo. Pass the base URL as the second argument to fromHTML($html, $baseUrl) and use hQuery::abs_url($href, $baseUrl) (or Guzzle's UriResolver) — otherwise you'll be crawling broken links.data-src images will be invisible. If content requires JS, this is the wrong package; reach for a headless browser.Content-Type/meta charset and run mb_convert_encoding($html, 'UTF-8', $charset) before parsing.->html() is innerHTML, not outerHTML. Casting a Node to string ((string) $node) yields the full tag if you need the outer markup for storage.:has(), :nth-last-child(), and some comma-nested expressions won't behave like jQuery/browser CSS. Test selectors against real fixtures before shipping; keep them simple.if (count($doc->find('.x'))) or check for empty arrays — there's no null to fall back on, which is actually convenient but easy to forget.hQuery::$cache_path and hQuery::$cache_expires control the built-in file cache, and other statics affect parsing. In Octane or long-running workers, reset anything you mutate between requests to avoid config bleed.unset($doc) after extracting data in long queue workers — otherwise memory creeps across thousands of jobs.hQuery to add helpers (e.g. ->absLinks(), ->textTrimmed()), and it's a cleaner place to centralize quirks than sprinkling trim() calls through your code.fromUrl() defaults are thin. It doesn't gzip-decode reliably and doesn't retry. If you must use it, pass ['User-Agent' => ..., 'timeout' => 10] and treat failures as expected, not exceptional.How can I help you explore Laravel packages today?