symfony/css-selector
Converts CSS selectors to XPath expressions, letting you query HTML and XML documents with familiar CSS syntax. A port of Python's cssselect library, part of the Symfony framework.
Install (or discover you already have it).
composer require symfony/css-selector
laravel/dusk and anything depending on symfony/dom-crawler already pull this in transitively — check composer.lock before adding. Promoting it to a direct constraint is about pinning a minimum version and owning upgrades, not about new capability.
Where to look first. The stable public surface is tiny: Symfony\Component\CssSelector\CssSelectorConverter::toXPath(string $css, string $prefix = 'descendant-or-self::'), its constructor flag new CssSelectorConverter(bool $html = true), and the exception classes under Symfony\Component\CssSelector\Exception. Everything else (XPath\Translator, XPath\Extension\*, Node\*) is implementation detail — read it, don't build against it. The Symfony component docs page is the grammar reference; the package's own test fixtures are the fastest way to learn exactly which selectors compile.
First use case — assert the shape of rendered HTML in a test. This is the lowest-risk, highest-payoff entry point and it replaces fragile assertSee/regex assertions:
use Symfony\Component\DomCrawler\Crawler;
$html = view('mail.welcome', ['user' => $user])->render();
$crawler = new Crawler($html);
$this->assertCount(1, $crawler->filter('a.button[href*="/activate"]'));
$this->assertSame('Activate your account', $crawler->filter('a.button')->text());
The second use case — scraping — is the same code with Http::get(...)->body() as the input. Start there before designing anything larger.
1. Go through DomCrawler, not the converter, unless you have a reason. DomCrawler wraps DOMDocument/DOMXPath, tracks HTML vs XML, handles encoding, and gives you filter(), each(), text(), attr(). The converter alone gives you a string; you still have to build and maintain the DOM plumbing.
$crawler = new Crawler(Http::get($url)->body(), $url);
$products = $crawler->filter('ul.products > li')->each(function (Crawler $li) {
return [
'name' => $li->filter('h3')->text(),
'price' => $li->filter('.price')->text(),
'url' => $li->filter('a')->attr('href'),
];
});
2. Use the converter directly when the DOM already exists — bulk extraction, repeated queries against one document, or when you're handed a DOMXPath by other code. Compile once, query many times:
$converter = new CssSelectorConverter();
$xpath = new DOMXPath($document);
$nodes = $xpath->query($converter->toXPath('table.report tbody tr'));
foreach ($nodes as $node) { /* ... */ }
3. Keep selectors out of your classes. The most common maintenance failure in scraping code is hardcoded selector strings scattered across jobs. Put them in config/scrapers.php (or a dedicated SelectorSet value object) keyed by semantic name, so a markup change is a config change:
'products' => [
'row' => 'ul.products > li',
'name' => 'h3',
'price' => '.price',
],
4. Compile-and-cache for hot paths. toXPath() re-parses on every call. In an Octane or queue worker, memoise per instance with a bounded array (or Cache::remember with a TTL for user-authored selectors):
final class SelectorCompiler
{
/** @var array<string, string> */
private array $cache = [];
private CssSelectorConverter $converter;
public function __construct(bool $html = true)
{
$this->converter = new CssSelectorConverter($html);
}
public function toXPath(string $css): string
{
return $this->cache[$css] ??= $this->converter->toXPath($css);
}
}
5. Validate before you store. If users author selectors, add a form rule that compiles the selector in a dry run and surfaces a readable message:
use Symfony\Component\CssSelector\Exception\ParseException;
try {
(new CssSelectorConverter())->toXPath($request->string('selector'));
} catch (ParseException $e) {
throw ValidationException::withMessages([
'selector' => 'Unsupported or malformed selector: '.$e->getMessage(),
]);
}
6. Run scraping off the request thread. Http::get() + parse in a queued job with $timeout, and never let a user-supplied selector run inside a web request. This is also where you cap selector length and nesting depth before compilation.
7. In Dusk, remember you're in a different world. Dusk hands selectors to a real browser, where :hover, :focus, ::before, and :has() all work. The same string evaluated by CssSelectorConverter will throw. Don't share selector constants between server-side parsing and browser tests without checking both.
HTML vs XML is a constructor flag, and it matters. new CssSelectorConverter() assumes HTML and lowercases element names in the generated XPath (XPath 1.0 is case-sensitive; HTML isn't). For XML documents pass false, or use Crawler::addXmlContent() which tracks the mode for you. Getting this wrong produces silent zero-result queries on CamelCase XML tags.
Encoding bites raw DOMDocument. loadHTML() mangles UTF-8 unless an XML declaration or meta charset is present. DomCrawler handles it; if you build the DOM yourself, prefix with <?xml encoding="UTF-8"> or mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'). Also wrap parsing in libxml_use_internal_errors(true) and clear the buffer, or malformed markup floods your logs.
descendant-or-self:: means the root can match itself. $crawler->filter('div') where the crawler is rooted at a <div> returns that div. Useful, but surprising when you expected children only.
Relative/leading combinators aren't portable. ->filter('> li') is not reliable. Use ->filterXPath('./li') or ->children().
Know the grammar and publish it. Type/class/ID/attribute selectors (including ^=, $=, *=, ~=, |=), all four combinators, grouping, :first-child, :nth-child(), :not(), :contains() are supported. :is()/:where() arrive in 7.1, :has() in 8.1. Rendering and state pseudo-classes (:hover, :focus, ::before) are not supported and cannot be — there's no browser. :checked/:disabled/:enabled/:required compile to static attribute checks, so JS-toggled state will not be detected.
:has() may be unavailable to you. Symfony 8.x requires PHP ≥ 8.4; most Laravel apps on 8.2/8.3 resolve to 7.4.x. Emulate it in XPath instead: div.card[.//span[contains(@class,"badge")]].
:contains() is substring, case-sensitive, and not CSS. Don't use it for equality assertions; prefer text() and compare in PHP.
Pin a floor, not a ceiling. Releases have fixed denial-of-service-class defects that only appear with untrusted selectors: an LRU cache added to CssSelectorConverter to stop memory exhaustion (#63400, landed in 6.4.34 / 7.4.6 / 8.0.6), a nesting-depth cap on :is()/:where() (#65596, 8.1.6), and quadratic token probing in Reader::findPattern() (#65599, 8.1.6). If customers author selectors, a minimum-version constraint plus a patch SLA is a product-safety requirement, not housekeeping.
Catch ParseException, not something narrower. SyntaxErrorException and ExpressionErrorException both extend it. It extends \Exception — don't assume Laravel will render it nicely.
Guard empty result sets. Crawler::text() and attr() throw \InvalidArgumentException on an empty node list unless you pass a default: ->text(''), ->attr('href', '#'). Check ->count() first when a missing element is meaningful.
text() normalises whitespace by default. Pass the second argument false if exact spacing matters (e.g. diffing generated markup).
Namespaced XML is out of scope. The converter has no notion of namespace prefixes; drop to filterXPath() with registerNamespace() for Atom/RSS/SOAP payloads.
Debug by reading the XPath, not the HTML. When a selector returns nothing, dump (new CssSelectorConverter())->toXPath($css) and run it manually against the document — it almost always reveals an unexpected class-matching form (concat(' ', normalize-space(@class), ' ')) or a wrong HTML/XML mode.
Extend by composition. CssSelectorConverter is a stateless pure function; wrap it in your own compiler/validator service rather than subclassing or forking. Upgrades then become a composer update instead of a merge conflict, and symfony/symfony remains the single place to track issues and PRs across the 6.4/7.4/8.x branches.
How can I help you explore Laravel packages today?