phpgt/cssxpath
Lightweight, dependency-free PHP library that translates CSS selectors into XPath queries, bringing querySelectorAll-style DOM selection to PHP. Works with HTML and, via htmlMode: false, case-sensitive XML attributes.
Installation
composer require phpgt/cssxpath:^1.6
No service provider or config file is required. After upgrading an existing install, run composer dump-autoload (and php artisan optimize:clear if you deploy with --optimize-autoloader) so the new namespace mapping is picked up.
Namespace (changed in v1.6.0)
GT\CssXPathGt\CssXPath (still shipped for backwards compatibility)First Use Case Convert a CSS selector to an XPath expression and run it against a document:
use GT\CssXPath\Translator;
use DOMDocument;
use DOMXPath;
$dom = new DOMDocument();
$dom->loadHTML('<div class="content"><p>Hello</p></div>');
$xpath = new DOMXPath($dom);
$expression = (string) new Translator('div.content > p');
foreach ($xpath->query($expression) as $node) {
echo $node->textContent; // Hello
}
Where to Look First
composer.json autoload / autoload-dev blocks — this is where the GT\CssXPath ↔ Gt\CssXPath compatibility mapping lives, so it tells you exactly which namespaces are safe to import.CHANGELOG.md and the tagged diff for 1.6.0 (the release is namespace + tooling only; no behavioural changes).tests/ for selector-coverage examples you can copy into your own test suite.Wrap the converter in one place
Don't scatter new Translator(...) across controllers/jobs — wrap it so a future namespace or API change is a one-file edit:
namespace App\Support;
use GT\CssXPath\Translator;
class CssToXPath
{
public function __invoke(string $selector): string
{
return (string) new Translator($selector);
}
}
Bind it in a service provider if you want it injected:
$this->app->singleton(\App\Support\CssToXPath::class);
Integration with DOM parsing
use App\Support\CssToXPath;
use DOMDocument;
use DOMXPath;
$dom = new DOMDocument();
$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$nodes = $xpath->query(app(CssToXPath::class)('div.user span'));
Batch extraction (scraper / importer)
use App\Support\CssToXPath;
$fields = [
'title' => 'h1.product-title',
'price' => 'span.price',
'stock' => 'div.availability',
];
$convert = app(CssToXPath::class);
$xpath = new DOMXPath($dom);
$result = [];
foreach ($fields as $key => $selector) {
$node = $xpath->query($convert($selector))->item(0);
$result[$key] = $node?->textContent;
}
Convert each selector once, reuse the resulting expression across documents when the HTML structure is stable.
Blade / view helper
// AppServiceProvider::boot()
Blade::directive('css', fn ($selector) => "<?php echo e((string) new \\GT\\CssXPath\\Translator($selector)); ?>");
Translator instance. The object is not meant to be serialized; reconstitute it in the worker.public static function selectorProvider(): array
{
return [['div.content > p'], ['ul li:first-child'], ['a[href^="https"]']];
}
php-cs-fixer / Rector rule (or a simple grep -rl 'Gt\\CssXPath' app/ job) that rewrites imports to GT\CssXPath and lets normal PR flow handle the rest.DOMDocument usage in a small service that also handles libxml error suppression and encoding:
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
libxml_clear_errors();
static array $memo = []) when iterating large documents — conversion is cheap but not free.Trait/string-based class references (v1.6.0 namespace change) PHP class lookups are case-insensitive at runtime, but Composer's PSR-4 autoloading is case-sensitive per prefix. That is why the package ships the compatibility mapping rather than just renaming. Practical consequences:
instanceof GT\CssXPath\Translator and instanceof Gt\CssXPath\Translator both pass for the same object.get_class($obj) returns one canonical name, so string comparisons ($class === 'Gt\CssXPath\Translator') can fail after migration. Prefer instanceof or class-string constants.SomeClass::class, not hand-written strings.composer dump-autoload is required after upgrade if you run with an optimized/classmap-authoritative autoloader.Legacy imports still "work" — that's a trap
Mixing Gt\CssXPath and GT\CssXPath in the same codebase is accepted but will trip static analysis and confuse reviewers. Pick GT\CssXPath as the standard and flag the old one in code review or CI (! grep -rq 'Gt\\\\CssXPath' app/).
Attribute selectors
div[href] maps cleanly, but div[href^="http"], [data-*] and multi-value attribute selectors are where translation gets approximate. Always verify the generated expression against a real document before shipping.
Pseudo-classes
Structural pseudo-classes (e.g. :nth-child(2n)) expand to verbose expressions. They're correct but long — cache them, don't rebuild per row.
API surface used in older docs
Many snippets online call a static cssToXpath() on a CssXpath class and $dom->evaluate(...). Neither exists: the converter is the Translator (stringable) and querying is done with DOMXPath::query(). Don't copy those snippets verbatim.
Namespaces (XML)
No built-in namespace support for CSS like html|div. Register prefixes on DOMXPath (registerNamespace) and adapt the expression, or post-process the generated XPath. This is application-level, not something v1.6.0 changed.
Performance Don't convert selectors inside a per-node loop. Convert up front, reuse the expression.
$expression = (string) new Translator('div > p:first-of-type');
logger()->debug('CSS→XPath', ['expression' => $expression]);
There is no debug flag or logging hook in the package — wrap it yourself as above.DOMXPath::query() returns false (and emits a warning) for a malformed expression rather than throwing. Check the return value:
$nodes = $xpath->query($expression);
if ($nodes === false) {
throw new \RuntimeException("Invalid XPath: {$expression}");
}
libxml_use_internal_errors(true) and inspect libxml_get_errors() when loadHTML() returns odd results — it's usually malformed markup, not the selector.Keep the converter behind your own abstraction
Do not extend the package class. The release shows the namespace is still being settled; an interface/adapter in App\Support insulates you from both the namespace move and any future API change:
interface SelectorTranslator
{
public function toXPath(string $selector): string;
}
Pre/post-processing You can safely compose expressions with your own prefixes when scoping queries to a subtree:
$scoped = './/' . ltrim($expression, '/');
Testing
Unit-test your wrapper's output strings, and integration-test against fixture HTML with a real DOMDocument — mocking DOMDocument/DOMXPath produces tests that pass regardless of whether the expression works.
Sanitising user-supplied selectors The library is a translator, not a validator. Never pass raw request input in without an allow-list of permitted selectors/characters, and never build XPath by concatenating user strings.
How can I help you explore Laravel packages today?