webmozart/expression
A lightweight PHP library for building and evaluating expressions as objects. Compose comparisons and logical operators (and, or, not), then evaluate them against values for reusable validation, filtering, and rule logic without hard-coded conditionals.
Install and pin. The package is abandoned (last tagged release 1.x, December 2015), so treat it as a fixed dependency: composer require webmozart/expression:^1.3 and commit the lock file. Do not expect upstream fixes — budget for reading the source.
Look here first, in this order:
src/Expression.php — the two-method contract everything implements:
interface Expression {
public function evaluate($value); // bool
public function toString(); // human-readable rule
}
src/Expr.php — the static facade. It is the actual catalogue of what exists (constraints, logic combinators, selectors). Read it top to bottom once; it takes five minutes and saves you guessing class names.src/Constraint/, src/Logic/, src/Selector/ — the concrete node types. AndX / OrX are named with an X because and/or are reserved words.First use case — a reusable business rule instead of a boolean soup:
use Webmozart\Expression\Expr;
$eligible = Expr::greaterThanOrEqual(18)
->andLessThan(65)
->andEquals('active'); // note: Equals is loose (==); Same is strict (===)
$eligible->evaluate(30); // true
$eligible->evaluate(17); // false
(array) $eligible; // or: $eligible->toString()
// greaterThanOrEqual(18) && lessThan(65) && equals("active")
Because it implements Expression, you can type-hint business rules in signatures: function canCheckout(User $u, Expression $rule): bool. That is the whole value proposition — a rule stops being a closure buried in a controller and becomes a named, composable, printable object.
1. Compose rules, don't chain-construct them inline. Build small named factories once and combine at the call site. This is what makes the package pay off over array_filter callbacks:
final class UserRules
{
public static function adult(): Expression { return Expr::greaterThanOrEqual(18); }
public static function active(): Expression { return Expr::same('active'); }
}
$rule = Expr::andX(UserRules::adult(), UserRules::active(), Expr::not(Expr::isNull()));
array_filter($users, fn ($u) => $rule->evaluate($u)); // or use ->evaluate as callable
2. Filtering collections. evaluate() takes exactly one argument, so it is compatible with array_filter / Iterator filters when wrapped: array_filter($rows, [$rule, 'evaluate']).
3. Selectors for nested data. Do not flatten DTOs into arrays just to test them. Webmozart\Expression\Selector\Key, Property, and Method extract a sub-value before applying the inner expression:
use Webmozart\Expression\Selector\Key;
$rule = new Key('address', new Key('country', Expr::same('CH')));
$rule->evaluate(['address' => ['country' => 'CH']]); // true
Use Property for objects (public props / getters) and Method for computed values.
4. Config-driven rules. The single highest-leverage integration: persist rules as arrays/JSON and hydrate them into an expression tree in one factory. That turns "editable business logic" into a data problem rather than a deploy.
function build(array $node): Expression {
return match ($node['op']) {
'and' => Expr::andX(...array_map('build', $node['of'])),
'or' => Expr::orX(...array_map('build', $node['of'])),
'not' => Expr::not(build($node['of'])),
'eq' => Expr::same($node['value']),
'gt' => Expr::greaterThan($node['value']),
'in' => Expr::in($node['values']),
default => throw new InvalidArgumentException($node['op']),
};
}
5. Walking the tree — the extension point that matters. Traversal\ExpressionWalker + ExpressionVisitor let you convert an expression tree into something else (SQL WHERE, a Doctrine QueryBuilder, an Elasticsearch filter, an audit-log string). Write one visitor per target rather than one giant if on expression class names:
final class SqlVisitor implements ExpressionVisitor
{
public function visitSame(Same $expr): string { return $this->col.' = :p'.(++$this->i); }
public function visitGreaterThan(GreaterThan $e): string { /* ... */ }
// implement enterExpression()/leaveExpression() to wrap children in parentheses
}
This is the pattern that justifies the package over closures: a closure can only answer yes/no, an expression tree can be translated.
6. Validation / guard clauses. Use expressions to validate command and DTO input, and emit toString() in the exception message so the failure is self-describing: throw new DomainException('Rejected: '.$rule->toString()); That beats "constraint violation" messages for debuggability.
7. Testing. Table-drive against evaluate(): [[17, false], [18, true], [64, true], [65, false]]. Assert on toString() only for stable, hand-built trees — the output format is not a contract.
Equals is loose (==), Same is strict (===). This is the number-one source of silent bugs: Expr::equals(0)->evaluate(null) is true pre-PHP-8-ish semantics and '1' == '01' surprises abound. Default to Expr::same() for scalars unless you specifically want juggling.$expr->andX($other); alone does nothing useful — the combinator returns a new tree. Assign the result. This bites every first-time user coming from mutable builders.Expr::equals('foo') builds "the evaluated value equals 'foo'" — the expected value is the constructor argument, the actual value is the evaluate() argument. Reversing them still "works" for symmetric constraints and fails confusingly for StartsWith, Contains, GreaterThan.Key yields (null vs. exception) and prefer KeyExists/KeyNotExists in front of a deeper selector rather than relying on null propagation.AndX evaluates true and an empty OrX evaluates false (standard logic). A config-driven builder that silently produces an empty AndX from a malformed rule therefore allows everything. Validate your rule config before building.each()-era helpers. Wrap in your own test suite — the package's own CI is dead and will not catch platform regressions for you.fn ($v) => $rule->evaluate($v)), or bypass the package on the hot path.toString() is for humans; there is no parser to read it back, and not every constraint prints unambiguously. Serialize your config (arrays), never the toString() output.AndX/OrX nest, so a && (b || c) must be built as andX($a, orX($b, $c)). The tree — not operator precedence — is the semantics; draw it before you ship a complex rule.Constraint\* classes couples you to internal names that won't change (the package is frozen) but will read worse. Expr::* is the intended surface.Expression), you can vendor a 200-line replacement later or wrap the package behind your own Rule interface now. Do the wrap if this is load-bearing: it costs an hour and makes the abandonment a non-event.How can I help you explore Laravel packages today?