jaeger/phpquery-single
phpQuery-single is a single-file PHP port of jQuery's DOM API, letting you parse, query, and manipulate HTML/XML using familiar CSS selectors and chainable methods. No extensions or extra dependencies required—just drop it in and go.
Install
composer require jaeger/phpquery-single
There is nothing to register. This package is a single-file Composer distribution of the long-abandoned phpQuery library (jaeger/phpquery-single exists mainly because QueryList needs a Composer-installable copy). It ships a files autoload entry, so the moment composer dump-autoload runs you have a global phpQuery class and a global pq() function in every request. No service provider, no config file, no facade.
Where to look first
vendor/jaeger/phpquery-single/phpQuery.php — the entire library is this one file. Grep it; that is faster than searching for docs.First use case
Parsing an HTML page you fetched with Laravel's HTTP client — the classic "pull a list of items out of a page that has no API":
use Illuminate\Support\Facades\Http;
use phpQuery;
$html = Http::timeout(10)
->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; MyApp/1.0)'])
->get('https://example.com/products')
->body();
$doc = phpQuery::newDocumentHTML($html);
$products = $doc->find('.product')->map(function ($el) {
return [
'title' => trim(pq($el)->find('h2')->text()),
'price' => pq($el)->find('.price')->text(),
'url' => pq($el)->find('a')->attr('href'),
];
})->toArray();
phpQuery::unloadDocuments(); // see Gotchas — do not skip this
If that reads like jQuery, that is the whole pitch: find, filter, attr, text, html, each, eq, :contains(), [data-*], nth-child all behave the way your frontend instincts expect.
Wrap it — never sprinkle pq() through controllers
The library is a global static singleton with mutable document state. Isolate it behind one injectable class so you have a single place to call unloadDocuments() and a single place to swap in a different parser later:
namespace App\Support;
use phpQuery;
class HtmlDocument
{
public static function parse(string $html): \phpQueryObject
{
$previous = libxml_use_internal_errors(true);
try {
return phpQuery::newDocumentHTML(
mb_convert_encoding($html, 'UTF-8', 'UTF-8, ISO-8859-1, GBK'),
'utf-8'
);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
public static function release(): void
{
phpQuery::unloadDocuments();
}
}
Chain from the returned document, not from bare pq()
$doc->find(...) carries its own document ID and context, so it is re-entrant. Bare pq('a') resolves against phpQuery's implicit current document — the last one loaded. In a loop over ten pages, that is a bug waiting to happen:
// Safe: scoped to $doc
$links = $doc->find('nav a')->map(fn ($a) => pq($a)->attr('href'))->toArray();
// Fragile: depends on which document was loaded last
$links = pq('nav a')->map(fn ($a) => pq($a)->attr('href'))->toArray();
Bridge to Collections early
phpQuery objects are IteratorAggregate, Countable, and ArrayAccess. Convert at the boundary and stop thinking in phpQuery:
$rows = collect($doc->find('table.results tr')->slice(1)) // skip header
->map(fn ($tr) => collect(pq($tr)->find('td'))->map(fn ($td) => trim(pq($td)->text()))->all())
->filter(fn ($cells) => count($cells) === 4)
->values();
Content normalization / rewriting before storage
This is where phpQuery beats regex and beats raw DOMDocument in readability — rewriting stored HTML (making relative URLs absolute, stripping tracking pixels, unwrapping <font> tags) is a two-liner:
$doc->find('img')->each(function ($img) use ($base) {
$src = pq($img)->attr('src');
pq($img)->attr('src', url()->resolve($base, $src));
});
$doc->find('font')->contents()->unwrap(); // jQuery-style traversal
$clean = $doc->htmlOuter();
Queue it, don't inline it
Scraping in a request cycle is how you get 30-second timeouts. Push each URL to a job, and inside handle():
public function handle(): void
{
$doc = HtmlDocument::parse(Http::get($this->url)->body());
// ... persist ...
HtmlDocument::release(); // critical in long-running workers
}
Use Http::retry(3, 200), ->throw() for non-2xx, and respect robots.txt / rate limits yourself — the package does nothing for you there.
Testing
Http::fake() plus a fixture file gives you fully deterministic scraper tests:
Http::fake(['example.com/*' => Http::response(file_get_contents(base_path('tests/fixtures/products.html')))]);
$result = (new ScrapeProducts)->handle();
$this->assertCount(12, $result);
$this->assertSame('9.99', $result[0]['price']);
Assert on the extracted data, never on phpQuery internals.
Consider QueryList instead
If you are doing serious multi-page crawling rather than one-off parsing, reach for jaeger/querylist — it is the higher-level library built on top of this package, with built-in concurrency, pagination, and a plugin system. phpquery-single is the low-level primitive underneath it.
When not to use it at all
SimpleXML or DOMDocument + XPath. phpQuery's auto-detection between HTML and XML modes is a frequent source of surprise.mews/purifier. phpQuery is a parser, not a sanitizer; it will happily pass the contents of your <script> through.DOMXPath is faster and lighter; phpQuery's value is developer ergonomics, not throughput.Static document state is the #1 problem. Every newDocument*() call registers a document in phpQuery::$documents, and it stays there. In a queue worker, an Artisan command, or under Octane/Swoole/RoadRunner, that means unbounded memory growth and, worse, pq() silently resolving against a stale document. Call phpQuery::unloadDocuments() after each parse, or phpQuery::unloadDocuments($doc->getDocumentID()) for surgical cleanup. In Octane, add it to a RequestTerminated/OperationTerminated listener.
Encoding will bite you. phpQuery attempts to honor the meta charset it finds and may run mb_convert_encoding/iconv on your input. The reliable recipe is: convert to UTF-8 yourself before parsing, and pass 'utf-8' explicitly as the second argument. If you see mojibake or ? characters, this is why.
HTML5 tags and malformed markup. Under the hood this is libxml's HTML4 parser. <section>, <article>, <main>, <picture>, <template> can end up in odd places or closed incorrectly, and unclosed <p>/<li> will restructure your tree so selectors miss. Always wrap parsing in libxml_use_internal_errors(true) — otherwise a messy page emits a wall of DOMDocument::loadHTML(): Tag ... invalid warnings straight into your logs. If a selector returns nothing, dump $doc->htmlOuter() and look at what the parser actually built.
The global pq() function can collide. It is defined unconditionally at file load. In rare cases (another legacy library, a test bootstrap, a package with the same helper) you will get a fatal redeclare. If that happens, alias at the Composer level or abandon pq() in favor of explicit new phpQueryObject/method chaining from $doc.
Selector dialect is old jQuery. :contains() is supported; :has() works; :input, :first, :eq(n), :gt(n) work as jQuery extensions. But modern CSS like :is(), :where(), :has() in its CSS4 form, and attribute operators beyond *=, ^=, $=, ~=, |= behave inconsistently or not at all. When a selector fails, fall back to find('div')->filter(fn ($el) => ...).
text() vs html() vs htmlOuter(). text() returns the concatenated descendant text (no separator — <b>a</b><b>b</b> gives ab). html() gives inner HTML. htmlOuter() gives the element itself including its tag. attr() returns null for a missing attribute, so (string) pq($el)->attr('href') or ?? '' guard against nulls in typed arrays.
Attribute names and case. HTML attributes are lowercased by libxml. ->attr('data-id') works fine; ->attr('viewBox') on SVG will not — switch to XML mode, or use DOMDocument for SVG work entirely.
PHP 8 compatibility is "mostly fine, verify." This fork has picked up fixes over the years, but the package is unmaintained and 140 stars means a thin maintenance surface. Run your test suite with E_ALL on the target PHP version before committing to it — expect occasional deprecation notices around dynamic properties. Pin the version in composer.json.
Extension points are effectively "wrap it." There is a plugin mechanism (phpQueryObjectPlugin / phpQueryPlugin), but it is undocumented, untyped, and tied to the legacy architecture. Do not build on it. Your extension point is the service class you wrote in Implementation Patterns — that is what you refactor later when you migrate to Symfony DomCrawler (symfony/dom-crawler + symfony/css-selector is the modern, maintained drop-in for this job and supports CSS4 and HTML5 via masterminds/html5).
Version-control your fixtures. Because parser behavior differs subtly across libxml versions, a scraper that passes locally can fail in CI or production on a different libxml build. Fixture-based tests plus a pinned ext-libxml requirement in composer.json will save you a midnight debugging session.
How can I help you explore Laravel packages today?