wikimedia/zest-css
Zest is a fast PHP CSS selector parser and matcher for DOM documents, letting you query HTML/XML with familiar CSS selectors. Lightweight and standards-focused, it’s useful for scraping, testing, and DOM traversal.
Zest is a small, dependency-light CSS selector parser and matcher published by Wikimedia for MediaWiki's TemplateStyles extension. It is not a stylesheet parser, has no Laravel service provider, no facade, and no config file. You composer require wikimedia/zest-css and call static methods.
First steps:
composer require wikimedia/zest-css
Confirm you have ext-dom (included in the standard php-xml package on most Laravel images) — the matcher needs it.
Where to look first, in this order:
vendor/wikimedia/zest-css/README.md — the only real documentation.vendor/wikimedia/zest-css/tests/ — Zest's test suite carries the exact AST node shapes for every selector form. This is the fastest way to learn the schema; the README does not fully document it.vendor/wikimedia/zest-css/src/Zest.php — the entire public surface is a handful of static methods.The first use case that justifies pulling this in is almost always untrusted user-supplied CSS: a "custom theme", "brand CSS box", or a TemplateStyles-style feature where end users can write selectors but must not be able to leak data out of the page or blast the whole admin layout. The second is scoping those selectors to a container. The third is matching stored HTML against selectors for linting or analytics.
Minimal first use:
use Zest\Zest;
// Parse a selector list into an introspectable AST
$selectors = Zest::parse( '.card > a:hover, #hero' );
// Match against a DOM
$doc = new DOMDocument();
$doc->loadHTML( $html );
$nodes = Zest::querySelectorAll( 'a.btn', $doc );
If Zest::parse() throws on a bad selector, that is your error signal — wrap it and turn it into a 422, not a 500.
1. Allowlisting selectors (the main pattern). Walk the AST and reject any selector part whose type isn't in your allowlist. Because Zest gives you structured parts rather than a string, this is safer than regex.
namespace App\Support;
use Zest\Zest;
use Zest\Exception\InvalidSelectorException;
final class SelectorGuard
{
private const TYPES = [
'typeSelector', 'universalSelector', 'classSelector',
'idSelector', 'attributeSelector', 'pseudoClassSelector',
];
private const PSEUDO_CLASSES = [
'hover', 'focus', 'active', 'visited', 'first-child',
'last-child', 'nth-child', 'not', 'is', 'where',
];
public function check( string $selectorList ): bool
{
try {
$parsed = Zest::parse( $selectorList );
} catch ( InvalidSelectorException $e ) {
return false;
}
foreach ( $parsed as $parts ) {
foreach ( $parts as $part ) {
if ( !in_array( $part['type'], self::TYPES, true ) ) {
return false; // e.g. pseudoElementSelector, or a combinator marker
}
if (
$part['type'] === 'pseudoClassSelector'
&& !in_array( strtolower( $part['name'] ), self::PSEUDO_CLASSES, true )
) {
return false;
}
}
}
return true;
}
}
Bind it in AppServiceProvider ($this->app->singleton(SelectorGuard::class)) so you can swap it in tests, and expose it to forms as a Laravel rule: php artisan make:rule ValidCssSelector and delegate to the guard.
2. Pair it with a stylesheet parser. Zest parses selector lists only. A full user.css blob still needs a CSS parser to find rule preludes — sabberworm/php-css-parser is the usual choice. Flow: split the stylesheet into rules → hand each rule's selector prelude to Zest → validate parts → hand declarations to your own declaration filter for url(), expression(), @import.
3. Matching stored HTML. Store rendered email or CMS HTML, then use Zest::querySelectorAll() to build a "does this CSS actually apply?" linter (dead-rule detection), or to find which of a user's selectors target a fragment you render. Parse and cache the AST once; reuse it across many nodes rather than re-parsing per node.
4. Caching. Selectors change rarely, so cache parse results keyed by hash:
$ast = Cache::rememberForever(
'css-ast:' . sha1( $selector ),
fn () => Zest::parse( $selector )
);
Arrays cache cleanly; nothing in Zest is stateful, so it is Octane-safe.
.user-content), you cannot do it with Zest alone — either do careful string surgery on the original text or write your own serializer over the AST. Validate the result by re-parsing it.gerrit.wikimedia.org. Release cadence is driven by MediaWiki's needs, not yours — pin a version in composer.json and read the changelog before bumping. The 1.x → 2.x line changed API details around static methods and RemexHtml interop.::-moz-focus-inner, exotic pseudo-classes, :has() depending on version). Don't assume "parsed fine" means "matches correctly" — test matching against fixtures.DOMDocument::loadHTML() mangles <section>, <main>, <template> and self-closing voids, which will silently produce wrong querySelectorAll results on modern markup and email HTML. If match accuracy matters, parse with RemexHtml's DOMBuilder or masterminds/html5 and feed Zest the resulting nodes.background: url(https://attacker/…), @import, and legacy expression() still need their own filter. Do not ship a "CSS sanitizer" that is only a SelectorGuard.Zest::parse() throwing on user input is normal control flow — catch Zest\Exception\InvalidSelectorException, log the raw selector, and return a validation message. Never let it bubble to a 500.symfony/css-selector first. If all you need is DomCrawler::filter('a.btn')-style matching, Symfony's converter (CSS → XPath) is already in most Laravel apps and needs no new dependency. Zest wins specifically when you need the AST to introspect and allowlist, or a pure-PHP matcher with no XPath layer.dd( Zest::parse( ':not(.a > .b)' ) ) is the fastest way to learn the node shape for a given construct; keep a fixture file of the selectors your product actually permits and assert the guard's verdicts against it.How can I help you explore Laravel packages today?