Let’s Make A Simple Rule Engine

İbrahim Gündüz
Developer Space
Published in
9 min readJan 13, 2018

Today, I want to explain how we can make a simple rule engine for a web application in an easy way.

Basically we need to:

  • An event dispatcher mechanism to trigger rule engine and realise something when the rules matched with input argument.
  • Matcher classes which is extended FilterIterator class, for checking whether the input argument matched with the rule that represented by the class or not.

We’re gonna to do this implementation on a symfony framework but you can do the same thing with any mvc web framework.

Let’s assume, we have an e-commerce company and we’re planning to give some simple gifts to the customers when they bought something in special categories and of course if their cart total greater than a specific threshold.

So as you can see, we have two condition we need to validate:

  • Customer should add some product from some specific categories. So we have to validate the product category.
  • The cart total should be greater than a specific threshold, even the first condition happened. So we have to validate the cart subtotal.

First of all, let’s define cart and cart item entities.

<?php# src/CartBundle/Entity/Cart.phpnamespace CartBundle\Entity;use Doctrine\ORM\Mapping as ORM;/**
* @ORM\Entity
* @ORM\Table(name="cart")
*/
class Cart
{
/**
*
@ORM\Id
* @ORM\Column(name="id", type="integer")
*
@ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
*
@ORM\OneToMany(targetEntity="\CartBundle\Entity\Item", mappedBy="cart")
*/
private $items;

/**
*
@return mixed
*/
public function getId()
{
return $this->id;
}

/**
*
@param mixed $id
*/
public function setId($id)
{
$this->id = $id;
}

/**
*
@return mixed
*/
public function getItems()
{
return $this->items;
}

/**
*
@param mixed $items
*/
public function setItems($items)
{
$this->items = $items;
}

/**
*
@return mixed
*/
public function getSubTotal()
{
return $this->subTotal;
}

/**
*
@param mixed $subTotal
*/
public function setSubTotal($subTotal)
{
$this->subTotal = $subTotal;
}

/**
*
@ORM\Column(name="sub_total", type="decimal", precision=10, scale=2)
*/
private $subTotal;
}

And the following one‘ll’ be our Item entity:

# CartBundle/Entity/Cart/Item.php;namespace CartBundle\Entity\Cart;use Doctrine\ORM\Mapping as ORM;
use CartBundle\Entity\Cart;
/**
* @ORM\Entity
* @ORM\Table(name="cart_item")
*/
class Item
{
/**
*
@ORM\Id
* @ORM\Column(name="id", type="integer")
*
@ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
*
@ORM\ManyToOne(targetEntity="\CartBundle\Entity\Cart")
*
@ORM\JoinedColumn(name="cart_id", referencedColumnName="id")
*/
private $cart;
/**
*
@ORM\Column(name="category_id", type="integer")
*/
private $categoryId;
/**
*
@ORM\Column(name="name", type="string", length=50)
*/
private $name;
/**
*
@ORM\Column(name="price", type="decimal", precision=10, scale=2)
*/
private $price;

/**
*
@return mixed
*/
public function getId()
{
return $this->id;
}

/**
*
@param mixed $id
*/
public function setId($id)
{
$this->id = $id;
}

/**
*
@return mixed
*/
public function getCart()
{
return $this->cart;
}

/**
*
@param mixed $cart
*/
public function setCart($cart)
{
$this->cart = $cart;
}

/**
*
@return mixed
*/
public function getCategoryId()
{
return $this->categoryId;
}

/**
*
@param mixed $categoryId
*/
public function setCategoryId($categoryId)
{
$this->categoryId = $categoryId;
}

/**
*
@return mixed
*/
public function getName()
{
return $this->name;
}

/**
*
@param mixed $name
*/
public function setName($name)
{
$this->name = $name;
}

/**
*
@return mixed
*/
public function getPrice()
{
return $this->price;
}

/**
*
@param mixed $price
*/
public function setPrice($price)
{
$this->price = $price;
}
}

Now we are ready to develop our rule engine. We’ll realize the following tasks:

  • Create entities to store your scenarios.
  • Create event argument classes
  • Create matcher classes to check the input argument matched with given condition.
  • Create a service to be applied the rules.
  • Create an event listener to trigger rule engine.
  • Create an event listener to add gift product to cart.

Let’s start!

Create Entities To Store Your Scenarios

We have to create some entities to store our rule data. Next time we will call the rule as Scenario.

<?php
namespace
CartBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
* Class Scenario
*
@package CartBundle\Rules\Entity
*
@ORM\Entity(repository=CartBundle\Entity\ScenarioRepository)
*
@ORM\Table(name="cartrules_scenario")
*/
class Scenario
{
/**
*
@var int
*
@ORM\Id
* @ORM\Column(name="id", type="integer")
*
@ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
*
@var string
*
@ORM\Column(name="name", type="string", length=50)
*/
private $name;

/**
*
@var string
*
@ORM\Column(name="event_name", type="string", length=50)
*/
private $eventName;

/**
*
@var ArrayCollection
*
@ORM\OneToMany(targetEntity="\CartBundle\Entity\Scenario\Action", mappedBy="scenario")
*/
private $actions;

/**
*
@var string
*
@ORM\Column(name="config", type="text")
*/
private $config;

/**
*
@var bool
*
@ORM\Column(name="is_enabled", type="boolean")
*/
private $isEnabled;

/**
* Scenario constructor.
*
@param ArrayCollection $actions
*/
public function __construct()
{
$this->actions = new ArrayCollection();
$this->isEnabled = true;
}

/**
*
@return int
*/
public function getId()
{
return $this->id;
}

/**
*
@param int $id
*/
public function setId($id)
{
$this->id = $id;
}

/**
*
@return string
*/
public function getName()
{
return $this->name;
}

/**
*
@param string $name
*/
public function setName($name)
{
$this->name = $name;
}

/**
*
@return string
*/
public function getEventName()
{
return $this->eventName;
}

/**
*
@param string $eventName
*/
public function setEventName($eventName)
{
$this->eventName = $eventName;
}

/**
*
@return ArrayCollection
*/
public function getActions()
{
return $this->actions;
}

/**
*
@param ArrayCollection $actions
*/
public function setActions($actions)
{
$this->actions = $actions;
}

/**
*
@return string
*/
public function getConfig()
{
return $this->config;
}

/**
*
@param string $config
*/
public function setConfig($config)
{
$this->config = $config;
}

/**
*
@return bool
*/
public function isIsEnabled()
{
return $this->isEnabled;
}

/**
*
@param bool $isEnabled
*/
public function setIsEnabled($isEnabled)
{
$this->isEnabled = $isEnabled;
}
}

Our scenarios can run multiple action when needed conditions happened. So scenario and action entities have one to many relation between each other.

<?php
namespace
CartBundle\Entity\Scenario;

use Doctrine\ORM\Mapping as ORM;

/**
* Class Action
*
@package CartBundle\Rules\Entity\Action
*
@ORM\Entity
* @ORM\Table(name="cartrules_scenario_action")
*/
class Action
{
/**
*
@var int
*
@ORM\Id
* @ORM\Column(name="id", type="integer")
*
@ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
*
@var string
*
@ORM\Column(name="event_name", type="string", length=50)
*/
private $eventName;

/**
*
@var bool
*
@ORM\Column(name="is_enabled", type="boolean")
*/
private $isEnabled;

/**
*
@var Scenario
*
@ORM\ManyToOne(targetEntity="\CartBundle\Entity\Scenario")
*
@ORM\JoinedColumn(name="scenario_id", referencedColumnName="id")
*/
private $scenario;

public function __construct()
{
$this->isEnabled = false;
}

/**
*
@return int
*/
public function getId()
{
return $this->id;
}

/**
*
@param int $id
*/
public function setId($id)
{
$this->id = $id;
}

/**
*
@return string
*/
public function getEventName()
{
return $this->eventName;
}

/**
*
@param string $eventName
*/
public function setEventName($eventName)
{
$this->eventName = $eventName;
}

/**
*
@return bool
*/
public function isIsEnabled()
{
return $this->isEnabled;
}

/**
*
@param bool $isEnabled
*/
public function setIsEnabled($isEnabled)
{
$this->isEnabled = $isEnabled;
}

/**
*
@return Scenario
*/
public function getScenario()
{
return $this->scenario;
}

/**
*
@param Scenario $scenario
*/
public function setScenario($scenario)
{
$this->scenario = $scenario;
}
}

Then, Let’s create the repository we defined on Scenario entity.

namespace CartBundle\Entity;

use Doctrine\ORM\EntityRepository;

class ScenarioRepository extends EntityRepository
{
public function findByEvent($eventName)
{
return $this->createQueryBuilder('s')
->join('s.actions', 'a')
->where('s.isEnabled=true')
->andWhere('a.isEnabled=true')
->andWhere('s.eventName=:event_name')
->setParameter('event_name', $eventName)
->getQuery()
->getResult();
}
}

Create Event Argument Classes

We will have two event class for passing some data form event places to event listeners.

The first one will help us to transfer current cart data to rule engine.

namespace CartBundle\Event;

use Symfony\Component\EventDispatcher\Event;

class CartEvent extends Event
{
const CART_UPDATED = 'cart.updated';

/** @var string */
private $eventName;

/** @var \CartBundle\Entity\Cart */
private $cart;

/**
* CartEvent constructor.
*
@param string $eventName
*
@param CartBundle\Entity\Cart $cart
*/
public function __construct($eventName, CartBundle\Entity\Cart $cart)
{
$this->eventName = $eventName;
$this->cart = $cart;
}

/**
*
@return string
*/
public function getEventName()
{
return $this->eventName;
}

/**
*
@return CartBundle\Entity\Cart
*/
public function getCart()
{
return $this->cart;
}
}

Other one will help us to passing the current cart to action event listeners to perform needed action when any scenario matched with the cart.

namespace CartBundle\Event;

use Symfony\Component\EventDispatcher\Event;

class ActionEvent extends Event
{
const ADD_GIFT_TO_CART = 'action.add_gift_to_cart';

/** @var string */
private $eventName;

/** @var \CartBundle\Entity\Cart */
private $cart;

/**
* CartEvent constructor.
*
@param string $eventName
*
@param CartBundle\Entity\Cart $cart
*/
public function __construct($eventName, CartBundle\Entity\Cart $cart)
{
$this->eventName = $eventName;
$this->cart = $cart;
}

/**
*
@return string
*/
public function getEventName()
{
return $this->eventName;
}

/**
*
@return CartBundle\Entity\Cart
*/
public function getCart()
{
return $this->cart;
}
}

Create Matcher Classes To Check The Input Argument Matched With Given Condition

We will have very simple classes that just checked single condition. We’ll use one of the useful built-in PHP library which called FilterIterator.

<?php
namespace
CartBundle\Rules\Matcher;

use Iterator;
use Symfony\Component\OptionsResolver\OptionsResolver;

abstract class AbstractMatcher extends \FilterIterator
{
/**
*
@var array
*/
private $filter;

public function __construct(Iterator $iterator, array $filter)
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$this->filter = $resolver->resolve($filter);
parent::__construct($iterator);
}

protected function getCart()
{
return $this->getInnerIterator()->getCart();
}

/**
*
@return array
*/
public function getFilter()
{
return $this->filter;
}

/**
*
@param OptionsResolver $resolver
*/
abstract protected function configureOptions(OptionsResolver $resolver);
}

We need to allow an iterator object and filter configuration in all of matchers. So the above class will be our base class. Let’s continue to development with creating Category matcher.

<?php
namespace
CartBundle\Rules\Matcher;
use Symfony\Component\OptionsResolver\OptionsResolver;class CategoryMatcher extends AbstractMatcher
{
public function accept()
{
$cart = $this->getCart();
$filter = $this->getFilter();

/** @var array $categories */
$categories = $filter['category'];
foreach ($cart->getItems() as $item) {
if (in_array($item->getCategory(), $categories)) {
return true;
}
}
return false;
}

/**
*
@param OptionsResolver $resolver
*/
protected function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined(array_keys($this->getFilter()))
->setAllowedTypes('category', 'array');
}
}

As you seen, it just validate whether the cart items are compatible with given condition or not. Of course after the configuration validation. Then, let’s make the sub total matcher class.

<?php
namespace
CartBundle\Rules\Matcher;
use Symfony\Component\OptionsResolver\OptionsResolver;class SubTotalMatcher extends AbstractMatcher
{
public function accept()
{
$cart = $this->getCart();
$filter = $this->getFilter();

/** @var float $subTotal */
$subTotal = $filter['sub_total'];
$condition = $filter['condition'];
$cartSubTotal = $cart->getSubTotal();

return eval(sprintf('return %s %s %s;', $cartSubTotal, $condition, $subTotal));
}

/**
*
@param OptionsResolver $resolver
*/
protected function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined(array_keys($this->getFilter()))
->setAllowedTypes('sub_total', 'numeric')
->setAllowedTypes('condition', 'string')
->setAllowedValues('condition', ['=', '<', '>', '<=', '>=']);
}
}

So we finished the first task. Let’s continue…

Create A Service To Be Applied The Rules

This implementation will be used by Input event listener to apply the existing rules to cart object. It will be a kind of facade class between rule system and event listener to simplify communication between each other.

namespace CartBundle;

use CartBundle\Entity\Cart;
use CartBundle\Entity\Scenario;
use CartBundle\Entity\ScenarioRepository;
use CartBundle\Rules\Matcher\CategoryMatcher;
use CartBundle\Rules\Matcher\SubTotalMatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use CartBundle\Event\ActionEvent;

class RuleEngine
{
/**
*
@var ScenarioRepository
*/
private $scenarioRepository;

/**
*
@var EventDispatcherInterface
*/
private $eventDispatcher;

/**
* RuleEngine constructor.
*
@param ScenarioRepository $scenarioRepository
*
@param EventDispatcherInterface $eventDispatcher
*/
public function __construct(ScenarioRepository $scenarioRepository, EventDispatcherInterface $eventDispatcher)
{
$this->scenarioRepository = $scenarioRepository;
$this->eventDispatcher = $eventDispatcher;
}

private function isMatched(Scenario $scenario, \Iterator $iterator)
{
$config = json_decode($scenario->getConfig(), true);

if (!$config) {
throw new \InvalidArgumentException('Bad configuration');
}

foreach ($config as $matcherType => $matcherConfig) {
switch ($matcherType) {
case 'category':
$iterator = new CategoryMatcher($iterator, $matcherConfig);
break;
case 'sub_total':
$iterator = new SubTotalMatcher($iterator, $matcherConfig);
break;
}
}

return (bool) $iterator;
}

public function dispatchActionEvents(Scenario $scenario, Cart $cart)
{
/** @var \CartBundle\\Entity\Action $action */
foreach ($cart->getActions() as $action) {
$this->eventDispatcher->dispatch($action->getEventName(), new ActionEvent($action->getEventName(), $cart));
}
}

/**
*
@param $eventName
*
@param Cart $car
*/
public function apply($eventName, Cart $car)
{
$scenarios = $this->scenarioRepository->findByEvent($eventName);

$iterator = new class ($car) extends \ArrayIterator {
/** @var Cart */
private $cart;

public function __construct(Cart $cart)
{
$this->cart = $cart;
}


public function getCart()
{
return $this->cart;
}
}

/** @var Scenario $scenario */
foreach ($scenarios as $scenario) {
if ($this->isMatched($scenario, $iterator)) {
$this->dispatchActionEvents($scenario, $cart);
}
}
}
}

Then, register the class to pass needed dependencies.

# CartBundles\Resources\config\services.ymlservices:
...
cart.entity.scenario_repository:
class: CartBundle\Entity\ScenarioRepository
factory: ['@doctrine.orm.default_entity_manager', getRepository]
cart.rule_engine:
class: CartBundle\RuleEngine
arguments:
- '@cart.entity.scenario_repository'
- '@event_dispatcher'

Create An Event Listener To Trigger Rule Engine

This listener will help to us to apply the defined rules when “cart updated” event triggered.

namespace CartBundle\EventListener;

use CartBundle\Event\CartEvent;
use CartBundle\RuleEngine;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CartListener implements EventSubscriberInterface
{
/**
*
@var RuleEngine
*/
private $engine;

/** @var LoggerInterface */
private $logger;

/**
* CartListener constructor.
*
@param RuleEngine $engine
*
@param LoggerInterface $logger
*/
public function __construct(RuleEngine $engine, LoggerInterface $logger)
{
$this->engine = $engine;
$this->logger = $logger;
}

/**
*
@return array
*/
public static function getSubscribedEvents()
{
return [
CartEvent::CART_UPDATED => 'handleEvent'
];
}

/**
*
@param CartEvent $event
*/
public function handleEvent(CartEvent $event)
{
try {
$this->engine->apply($event->getEventName(), $event->getCart());
} catch (\Exception $exception) {
$this->logger->error($exception);
}
}
}

And.. register the event subscriber as a service.

# services.ymlcart.event_listener.cart_listener:
class: CartBundle\EventListener\CartListener
tags:
- { name: kernel.event_subscriber }
arguments:
- "@cart.rule_engine"
- "@logger"

Create An Event Listener To Add A Gift Product To Cart

This listener will be add a new product, when it needed. We can create and define many events and listeners to perform various actions.

namespace CartBundle\EventListener;

use Psr\Log\LoggerInterface;

class GiftListener
{
/**
*
@var LoggerInterface
*/
private $logger;

/**
* GiftListener constructor.
*
@param LoggerInterface $logger
*/
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}

public function handleEvent()
{
//Write some logic...
}
}

And.. define the class as a service also.

# services.ymlcart.event_listener.gift_listener:
class: CartBundle\EventListener\GiftListener
tags:
- { name: kernel.event_listener, event: action.add_gift_to_cart, method: handleEvent }
arguments:
- "@logger"

Finally, dispatch “cart updated” event in your “Add To Cart” action of your Cart controller.

namespace CartBundle\Controller;

use CartBundle\Event\CartEvent;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class CartController extends Controller
{
public function addToCartAction(Request $request)
{
//...
/** @var \CartBundle\Entity\Cart $cart */
$cart = $this->get('cart');
$event = new CartEvent(CartEvent::CART_UPDATED, $cart);
$this->get('event_dispatcher')->dispatch(CartEvent::CART_UPDATED, $event);
//...
}
}

That’s it!

Image source:

https://www.cannalawblog.com/oregon-cannabis-who-are-the-new-rules-really-for/

--

--