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

Expression Language Laravel Package

symfony/expression-language

Symfony's ExpressionLanguage component compiles and evaluates one-line expressions that return a value, usually a boolean. It's ideal for dynamic rules in configuration, security, and workflows, offering a safe parser and evaluator with no eval required.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. Install the Package (pin the security floor):

    composer require symfony/expression-language:"^5.4.54"
    

    v5.4.54 is a security release. If you already have the package, bump it now and run composer update symfony/expression-language — the fix bounds the depth of the parsed node tree, closing a denial-of-service vector where a hostile (or merely generated) expression could exhaust the stack. If you are on a different maintained branch than 5.4, cross-check that you are on the equivalent patched build before assuming you are covered.

    Laravel needs no configuration of its own — this is a standalone Symfony component. Everything day-to-day comes from binding it once.

  2. Bind it once in a service provider:

    // app/Providers/AppServiceProvider.php
    public function register(): void
    {
        $this->app->singleton(ExpressionLanguage::class, function () {
            $language = new ExpressionLanguage();
            $language->addFunction('is_vip', fn ($u) => $u, fn ($u) => $u->subscription === 'vip');
            return $language;
        });
    }
    

    Resolve via app(ExpressionLanguage::class) (or inject it) — never new ExpressionLanguage() inside a policy or controller, or you will re-register functions and lose the cache.

  3. Basic Evaluation:

    use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
    
    $language = app(ExpressionLanguage::class);
    
    $result = $language->evaluate(
        "user.isAdmin() and model.isActive()",
        ['user' => $user, 'model' => $model]
    );
    

    Note the operator is and, not && — see Gotchas.

  4. First Use Case: move one hardcoded rule out of a policy:

    // Before
    public function update(User $user, Post $post): bool
    {
        return $user->isAdmin() && $post->isActive();
    }
    
    // After — expression is data (DB column, config, feature flag row)
    public function update(User $user, Post $post): bool
    {
        return app(ExpressionLanguage::class)->evaluate(
            $post->update_rule,          // "user.isAdmin() and model.isActive()"
            ['user' => $user, 'model' => $post]
        );
    }
    

    Ship with a default expression in config and a lint() check in CI so a bad rule can't take down authorization.


Implementation Patterns

Core Workflows

1. Expressions as authorization data

  • Store the expression in a rules / policy_expressions table keyed by ability + model type, with a version column and an updated_by audit trail.
  • Resolve in one place rather than scattering evaluate() calls:
    class ExpressionGate
    {
        public function __construct(private ExpressionLanguage $language) {}
    
        public function allows(string $ability, array $vars): bool
        {
            $rule = PolicyExpression::for($ability)->firstOrFail();
    
            try {
                return (bool) $this->language->evaluate($rule->expression, $vars);
            } catch (\Throwable $e) {
                report($e);           // never let an expression break authorization loudly
                return false;         // fail closed
            }
        }
    }
    
  • Validate on write, not on read: run $language->lint($expression, array_keys($vars)) in the save path (form request / Filament action), so broken expressions never reach production.

2. Middleware / gate integration

Gate::define('access-resource', function (User $user, string $resource) {
    return app(ExpressionGate::class)->allows('access-resource', [
        'user' => $user,
        'resource' => $resource,
        'now' => now(),
    ]);
});

Because the rule is looked up at request time, you get a single cache key per (ability, tenant) — cheap to Cache::remember().

3. Validation rules from configuration

$rules = config('validation.email');   // "input is string and input.matches('/regex/')"
$valid = $language->evaluate(config('validation.email'), ['input' => $value, 'request' => $request]);

Wrap it in a real Rule object so it composes with Laravel's validator instead of replacing it.

4. Feature flags with context

$enabled = $language->evaluate(
    $flag->expression,                    // "user.isBetaTester() or request.header('X-Dogfood') == '1'"
    ['user' => $request->user(), 'request' => $request, 'date' => now()]
);

Keep the expression purely a read of the passed-in context — no DB calls from inside expressions.

5. Extending: custom functions (correct signature)

addFunction takes two callables — the compiler (builds PHP source for compile()) and the evaluator (runs during evaluate()). The one-closure form does not exist on 5.4:

$language->addFunction(
    'is_vip',
    fn ($user) => '$__vip->check(' . $user . ')',   // compiler: returns PHP code string
    fn ($user) => $user->subscription === 'vip'      // evaluator
);
// expression: "is_vip(user)"

If you only ever call evaluate() you still must supply a compiler — pass a harmless placeholder, or wrap with ExpressionFunction::fromPhp('is_weekend') style helpers where available in your branch.

6. Caching the right thing

  • evaluate() re-parses the expression each time; on 5.4 there is no internal parse cache. If a rule is hit per-request at scale, cache the result (short TTL, keyed by rule version + user id), which is cheaper and safer than caching PHP source.
  • Rule updates must bust the cache. Version the rule row and include v{$rule->version} in the key — a stale cache is the most common "flag didn't turn on" support ticket.

Integration Tips

  • Policies: keep Laravel's Policy signature and delegate the body to the expression gate. Do not monkey-patch Authorizable — Laravel's authorize() returns Response objects and interacts with Gate; bypassing it loses Gate::before, can middleware and Blade @can.
  • Blade: resolve the gate, not the language — @can('update', $post) stays unchanged.
  • Tenancy: pass the tenant in the variable array explicitly ('tenant' => $tenant). Never read auth()->user() from inside a custom function; it hides a dependency and breaks queued/console use.
  • Queues: expressions that use request() will blow up in jobs. Keep a separate expression set for console contexts or hydrate the context into the variable array.
  • Testing: one test per stored expression in your fixture set, asserting both allowed and denied. Expression bugs are data bugs and won't show up in feature tests unless you exercise them.

Gotchas and Tips

Pitfalls

  1. Deeply nested expressions (the v5.4.54 security fix)

    • Issue: Prior to 5.4.54, parsing had no depth bound — a sufficiently nested expression could exhaust the stack (DoS). If expressions are user-editable at all, this was remotely triggerable.
    • Action: Upgrade to >=5.4.54. Then re-run your fixture/lint suite: expressions that previously "worked" may now throw Symfony\Component\ExpressionLanguage\SyntaxError once the nesting bound is hit.
    • Also: keep a hard cap on expression length and depth at the write path (e.g. strlen($expr) < 500, max 3 paren levels). Defence in depth, and a much better error message than a stack trace.
  2. Chained ternaries now nest correctly

    • 5.4.54 fixed the nesting level assigned to chained ternaries. Consequences: expressions relying on the old (incorrect) nesting may evaluate differently after the upgrade, and some very long ternary chains may now trip the depth bound instead of parsing.
    • Audit any a ? b : c ? d : e in stored rules. Prefer (a ? b : c) ? d : e-style explicit parentheses — the component's ternary associativity is not PHP's, and implicit chains are the single most confusing thing to debug here.
  3. compile() returns a string, not an object

    • $language->compile($expr) returns PHP source code. You cannot call ->evaluate() on it. The only ways to use it are eval() (avoid) or writing a generated file/class (do this if you truly need speed). For everyday Laravel work, use evaluate() and cache results.
  4. Operator syntax is not PHP

    • Use and / or / not, ==/!=, and in / matches. &&, ||, !, ===, ?? and ?. are not supported on this branch. Null-safe navigation (?.) will parse-error, not silently fail.
    • Guard nulls explicitly: user == null ? false : user.isActive() — or filter the variable array before evaluating.
  5. Reserved / awkward names

    • in, not, and, or, matches, contains, starts with, instanceof cannot be used as bare variable names. If your context key collides, rename it at the boundary ('input' => ... not 'in' => ...).
  6. Custom providers add functions, not variables

    • registerProvider(ProviderInterface $provider) merges the provider's getFunctions(). It does not inject variables — the variable array you pass to evaluate() is the whole universe. Don't expect a provider to resolve user for you.
  7. PHP version and polyfills

    • The 5.4 branch still supports PHP 7.2.5+. 5.4.54 corrected polyfill declarations, so composer install on PHP 7.2/7.3 now pulls the polyfills actually required at runtime. If you support old PHP, this may change your lock file — commit it and run the suite on the lowest supported version. If you're on Laravel 10/11 you're ≥8.1 and unaffected in practice.
    • Note: expressions are not PHP, so match, arrow functions, named arguments, ?? and enums do not work inside them regardless of your PHP version.
  8. Fail-open by accident

    • An uncaught SyntaxError inside a Gate closure in some setups surfaces as a 500 (fine) — but inside a try/catch that returns true by default it's an instant authorization bypass. Always fail closed, and log the expression text with the exception.
  9. Expression bloat

    • Rules longer than a screen become unmaintainable and untestable. Split into named rules composed in PHP ($gate->allows('a') && $gate->allows('b')) rather than one mega-expression.

Debugging Tips

  • Lint before you evaluate: $language->lint($expr, ['user', 'model']) throws a precise SyntaxError with position info — perfect for a save-time validation rule.
  • Inspect the AST: (new Parser())->parse($expr, array_keys($vars)) returns the Node tree; dump() it when an expression "should" be true and isn't. Node class names tell you immediately whether your and/or precedence assumption was wrong.
  • Log the inputs, not just the output: a rule returning false is almost always a variable-shape mismatch (Eloquent model vs array, Collection vs array). Dump array_map('get_debug_type', $vars).
  • Test the nasty cases: empty string, whitespace-only, unclosed paren, user..method(), 50-deep nested parens, and a 5-link ternary chain.

Configuration Quirks

  • Register the singleton before anything resolves it; AppServiceProvider::register() is the right place. Registering custom functions in a boot() that runs after a policy resolution gives you a confusing "function not found" on first request only.
  • There's no config file to publish and no facade. Keep your own config/expressions.php with the allowed function list and default expressions so deploys are reviewable.
  • SyntaxError, LogicError (undefined function) and PHP-level TypeError are three distinct failure modes from the same evaluate() call — catch \Throwable at the boundary and normalise.

Extension Points

  1. Custom functions — the primary extension point for Laravel work. Wrap domain checks (is_vip, owns, has_role) so stored expressions stay short and the logic stays testable in PHP.
  2. Node visitors — addNodeVisitor() lets you walk the AST before evaluation. Useful for logging which attributes an expression touches (cheap audit trail) or rejecting nodes whose function names aren't on your allow-list.
  3. Expression functions backed by PHP callables — where your branch supports it, generate functions from closures instead of hand-writing compiler strings; a wrong compiler string only fails at compile() time, which you may never run in tests.
  4. Wrapper service — the most valuable extension point is not in the package: a thin ExpressionGate/RuleEvaluator of your own, holding the allow-listed functions, the lint step, the fail-closed behaviour and the cache. Keep every call site behind it so the next security release is a one-file review.
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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox