How I stopped hardcoding business rules in PHP - and built a rule engine to fix it
Source: Dev.to
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. 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. 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. 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 $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 $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.