Every PHP developer knows this situation: a client calls and says "I want free shipping for VIP customers on weekends, but only if the cart total is above €100."
You open your code. You find the shipping module. You add an if. You deploy.
Three weeks later: "Actually, make it €80. And also for the 'Premium' group."
You open your code again.
This loop : client request -> find logic in code -> modify -> deploy, was costing me a lot of time. And it's not just shipping. I build custom ecommerce solutions: payment modules, synchronization systems, pricing calculators. Business rules are everywhere, and they change constantly.
The obvious solution I didn't want Symfony's ExpressionLanguage exists and it's impressive. But it pulls in dependencies, it can traverse objects and call methods (which is a security concern when rules are authored by users), and when something goes wrong, it doesn't tell you why. It's a black box.
I needed something smaller, stricter, and transparent.
So I built php-ruler
I started with the classic pipeline: Lexer → AST → Evaluator. Strict typing from the start — 1 = '1' is a type error, not true. No silent coercion.
Then I added features one real problem at a time.
Problem: when something fails, why?
-> I built an explain mode that returns the full evaluation tree: which sub-conditions passed, which failed, which were short-circuited, and why a variable was missing.
Problem: in production, the context is sometimes incomplete
-> I built a safe mode that doesn't throw on missing variables — it collects them all and lets you decide what to do.
Problem: customer.group.name is not user-friendly
-> I built an alias resolver. As a developer, I expose what I want:
$resolver = (new AliasResolver())
->add('customer.group', 'customer group')
->add('cart.total', 'cart amount');
Now a non-developer can write: customer group = 'VIP' AND cart amount > 100
And I control exactly what variables are available to them.
A real example
Here's the shipping rule that started it all:
$eval = new ExpressionEvaluator();
$context = [
'customer' => ['group' => 'VIP'],
'cart' => ['total' => 150.00],
'day' => 'saturday',
];
$rule = "customer.group = 'VIP' AND cart.total > 100 AND day IN ['saturday', 'sunday']";
$eval->evaluateBoolean($rule, $context); // true -> free shipping
This rule lives in the database. When the client wants to change it, they change it - no deployment, no code change.
Same pattern for payment modules (who can use this payment method?), synchronization systems (apply a margin to these products above this price?), or any eligibility check.
What it looks like when something goes wrong
The explain mode is what I'm most proud of:
$explainer = new ExpressionExplainer($eval);
$result = $explainer->explain(
"customer.group = 'VIP' AND cart.total > 100",
$context
);
$result->passed; // true | false | null
$result->failures(); // leaves that returned false
$result->missing(); // variables that were absent
Every node in the tree carries its sub-expression, its status, and the resolved values. No more guessing why a rule didn't fire.
Zero dependencies. PHP 8.1+.
composer require ols/php-ruler
There's also a local demo playground (no build step, no Composer):
php -S localhost:8000 -t demo
-> github.com/olivier-ls/php-ruler
I built this because I needed it, and I've been running it in production for my own ecommerce clients. If you maintain systems where business rules change often, it might save you some late-night deploys.
Happy to answer questions.
Top comments (0)