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

Zest Css Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. vendor/wikimedia/zest-css/README.md — the only real documentation.
  2. 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.
  3. 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.

Implementation Patterns

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.

Gotchas and Tips

  • No serializer. Zest parses to an AST but will not round-trip it back to a string. If you want to scope selectors (prefix every rule with .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, not GitHub. The GitHub repo is a read-only mirror. Pull requests there are ignored; contributions and issue reports go through 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.
  • Parse vs. match semantics differ. Zest will happily parse syntactically valid selectors it cannot meaningfully match (::-moz-focus-inner, exotic pseudo-classes, :has() depending on version). Don't assume "parsed fine" means "matches correctly" — test matching against fixtures.
  • DOMDocument is a poor HTML5 parser. 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.
  • Zest is only half of CSS sanitization. Allowlisting selectors does nothing about declarations. background: url(https://attacker/…), @import, and legacy expression() still need their own filter. Do not ship a "CSS sanitizer" that is only a SelectorGuard.
  • Exceptions are your friend. 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.
  • Compare with 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.
  • Debug by dumping. 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.
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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin