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

Phpquery Single Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.
  • The archived phpQuery wiki (Google Code era) and the QueryList docs. The package README is essentially a stub, so the de-facto documentation is jQuery's API surface, since phpQuery mirrors it closely.
  • Assume jQuery 1.3/1.4 semantics, not modern jQuery.

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.

Implementation Patterns

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

  • Well-formed XML/RSS/Atom/SVG → SimpleXML or DOMDocument + XPath. phpQuery's auto-detection between HTML and XML modes is a frequent source of surprise.
  • Sanitizing user-supplied HTML → HTMLPurifier or mews/purifier. phpQuery is a parser, not a sanitizer; it will happily pass the contents of your <script> through.
  • Simple attribute extraction at scale → raw DOMXPath is faster and lighter; phpQuery's value is developer ergonomics, not throughput.

Gotchas and Tips

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.

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