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.
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.
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.
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.
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.
rules / policy_expressions table keyed by ability + model type, with a version column and an updated_by audit trail.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
}
}
}
$language->lint($expression, array_keys($vars)) in the save path (form request / Filament action), so broken expressions never reach production.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().
$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.
$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.
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.
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.v{$rule->version} in the key — a stale cache is the most common "flag didn't turn on" support ticket.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.@can('update', $post) stays unchanged.'tenant' => $tenant). Never read auth()->user() from inside a custom function; it hides a dependency and breaks queued/console use.request() will blow up in jobs. Keep a separate expression set for console contexts or hydrate the context into the variable array.Deeply nested expressions (the v5.4.54 security fix)
>=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.strlen($expr) < 500, max 3 paren levels). Defence in depth, and a much better error message than a stack trace.Chained ternaries now nest correctly
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.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.Operator syntax is not PHP
and / or / not, ==/!=, and in / matches. &&, ||, !, ===, ?? and ?. are not supported on this branch. Null-safe navigation (?.) will parse-error, not silently fail.user == null ? false : user.isActive() — or filter the variable array before evaluating.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' => ...).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.PHP version and polyfills
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.match, arrow functions, named arguments, ?? and enums do not work inside them regardless of your PHP version.Fail-open by accident
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.Expression bloat
$gate->allows('a') && $gate->allows('b')) rather than one mega-expression.$language->lint($expr, ['user', 'model']) throws a precise SyntaxError with position info — perfect for a save-time validation rule.(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.false is almost always a variable-shape mismatch (Eloquent model vs array, Collection vs array). Dump array_map('get_debug_type', $vars).user..method(), 50-deep nested parens, and a 5-link ternary chain.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.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.is_vip, owns, has_role) so stored expressions stay short and the logic stays testable in PHP.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.compile() time, which you may never run in tests.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.How can I help you explore Laravel packages today?