DEV Community

Cover image for How I stopped hardcoding business rules in PHP - and built a rule engine to fix it
olivier-ls
olivier-ls

Posted on

How I stopped hardcoding business rules in PHP - and built a rule engine to fix it

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

There's also a local demo playground (no build step, no Composer):

php -S localhost:8000 -t demo
Enter fullscreen mode Exit fullscreen mode

-> 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)