tburry/pquery
pQuery is a jQuery-inspired PHP library for parsing and manipulating HTML. It offers CSS selector queries, chainable DOM traversal and modification, and a familiar API for scraping or transforming markup without fragile regex.
Install with Composer — the package has no dependencies and ships a global pq() helper via its autoload files:
composer require tburry/pquery
require 'vendor/autoload.php';
$doc = pq(file_get_contents('page.html')); // helper form
$doc = pq\Query\Query::create('<div><a href="/x">X</a></div>'); // explicit form
foreach ($doc->find('a') as $a) {
echo $a->attr('href'), ' => ', $a->text(), PHP_EOL;
}
Query::create() returns a pq\Dom\Document, which is itself an Element, so every selection method works on the root. find() returns a pq\Query\Query collection that is iterable and chainable.
Where to look first:
src/pq/Query.php — the jQuery-style surface (find, filter, each, attr, html, text, css, mutations).src/pq/Dom/ — Document, Element, Text, Comment, DocumentFragment, Attribute; this is where node-level behavior lives.First useful case: pull structured data out of a page without pulling in libxml/DOMDocument.
$rows = [];
foreach ($doc->find('table.results tbody tr') as $tr) {
$cells = $tr->find('td');
$rows[] = [
'name' => $cells->eq(0)->text(),
'price' => $cells->eq(1)->text(),
'link' => $cells->eq(0)->find('a')->attr('href'),
];
}
Load once, query many times. Parsing builds a lightweight PHP object tree. Parse at the boundary (HTTP response, file, fixture string) and pass the Document around rather than re-parsing.
$doc = pq($html);
$title = $doc->find('h1')->first()->text();
$nav = $doc->find('nav a')->items(); // raw array of Elements
Iterate with each when you also need the index, and foreach/items() when you don't:
$doc->find('li')->each(function ($i, $li) {
// $i is the position in the collection, $li is a pq\Dom\Element
echo $i, ': ', trim($li->text()), PHP_EOL;
});
Extraction helpers. text() collapses descendant text; html() returns inner markup; attr('href') returns the value or null; attr() with no arguments returns the attribute map; css('color') reads inline style; val() reads form values; hasClass()/is('.selector') for predicates.
Mutation and re-serialization. Strings passed to html(), append(), prepend(), before(), after() are parsed as fragments, so you build markup with markup:
$doc->find('.article')->append('<p class="note">Generated</p>');
$doc->find('.ad, .tracking')->remove(); // detach matching nodes
$doc->find('a[href^="http"]')->addClass('external');
echo (string) $doc; // Document::__toString renders full HTML
Filtering instead of re-selecting. Narrow an existing collection rather than walking the tree again: ->filter('.active'), ->not('.hidden'), ->has('img'), ->closest('.card'), ->parent(), ->children(), ->siblings(), ->next(), ->prev(). Traversal inside a loop is usually cheaper than a fresh find().
Composition for scrapers. Because the selector engine is hand-rolled and permissive, write selectors as small, testable steps rather than one giant CSS expression:
$cards = $doc->find('div.card');
foreach ($cards as $card) {
$title = $card->find('.title')->first()->text();
$tags = array_map(fn($t) => $t->text(), $card->find('.tag')->items());
}
Sanitizing/rewriting HTML in a pipeline. A common day-to-day use is post-processing stored markup: strip attributes, rewrite URLs, remove scripts, normalize classes — then persist (string) $doc. Keep a fixture in tests and assert on the serialized output so parser upgrades don't silently change behavior.
vendor/ or a VCS repository entry — upstream is dormant.:first, :last, :eq(), :gt(), :lt(), :contains(), :has(), :not(), :nth-child()) work, but complex combinators, namespaces, and esoteric pseudos may not. When a selector returns nothing, test the pieces separately before assuming your HTML is wrong.:contains() and attribute matching are case-sensitive. Normalize case yourself when scraping heterogeneous markup.->length in tests.children() skips them; raw iteration may not. trim() extracted text and don't be surprised by indentation inside html().mb_convert_encoding) and ensure a <meta charset> is present, or you'll get mojibake and mis-split characters.text($value) replaces children. Setting text clears descendant elements; use html() when you mean to replace inner markup. For HTML output, run html_entity_decode() if you need the entities resolved for downstream consumers.->remove() detaches but the Element object you hold is still valid. Clone explicitly if you need an independent copy for templating.find() walks the subtree, so hoist repeated selections out of loops and prefer filter()/traversal over re-querying. For megabyte documents, benchmark against DOMDocument + DOMXPath before committing.pq\Dom\Document / pq\Dom\Element / pq\Query\Query if you need custom node behavior or helper methods; instantiate your document class at the parse boundary and let find() produce your query subclass. Debug by dumping ->items() and (string) $el rather than var_dump-ing the whole tree.How can I help you explore Laravel packages today?