Currently available for new projects! Contact me

Using Symfony Expression Language

Using Symfony Expression Language Image

The Symfony2 Expression Language is a very great component allowing to create expressions that can be evaluated and compiled into plain PHP. It's mostly used by the Symfony2 Framework to add an new dimension of configuration but can be integrated into any PHP project.

One of the use cases I'm thinking about is a pricing engine that could be used in e-commerce software. Discount rules could be defined as expressions and applied to the base product price in order to calculate it's final price. Developers would be able to configure the pricing rules for their clients without having to write any line of code.

In most e-commerce CMS, users need to select values, operators, dates in order to produce very limited discount rules, while they could simply enter one line expressions to create rules of any complexity.

In order to understand this article you must already be familiar with the component. It's documentation can be found here.

The Product model

Firstly, we need to define our Product model as the discount rules will depend on the product attributes.

<?php

namespace MyProject\Model;

class Product
{
    /**
     * The base price.
     *
     * @var float
     */
    private $basePrice;

    /**
     * The number of items in stock.
     *
     * @var integer
     */
    private $stock;

    /**
     * The creation date of the product.
     *
     * @var \DateTime
     */
    private $creationDate;

    public function setBasePrice($basePrice)
    {
        $this->basePrice = $basePrice;
    }

    public function getBasePrice()
    {
        return $this->basePrice;
    }

    public function setStock($stock)
    {
        $this->stock = $stock;
    }

    public function getStock()
    {
        return $this->stock;
    }

    public function setCreationDate(\DateTime $creationDate)
    {
        $this->creationDate = $creationDate;
    }

    public function getCreationDate()
    {
        return $this->creationDate;
    }
}

Determining the basic rules and functions

Before coding anything we need to determine which functions we want to make available in our language.

We would like to create those kinds of rules:

apply a 10% discount if there is only one item left in stock
apply a 5% discount during the sales (from 20/01/2014 to 02/02/2014 for example)
apply a 50% discount if the product has been created more than one year ago and we have less than five items in stock

So we need the following functions:

  • date(time) This function returns the specified time as a DateTime. It will be needed to transform the dates 20/01/2014 and 02/02/2014 as DateTime instances to perform operations on them as specified in the second rule. It does the same as DateTime::__construct(time) in PHP.

  • date_modify(date, modify) This function modifies the date and returns it. It will be needed to increment the product creation date of one year to be used in the third rule. It is the same function as DateTime::modify(time) in PHP.

Translated into expressions (using our functions), the discount rules become:

product.getStock() == 1 ? 0.1 : 0
date('now') >= date('2014-01-20') and date('now') <= date('2014-02-02') ? 0.05 : 0
date('now') < date_modify(product.getCreationDate(), '+1 year') and product.getStock() < 5 ? 0.5 : 0

Using the ternary operator, each rule evaluates the discount coefficient that will be applied to the Product price (for example 0.1 for 10%).

Creating our DSL

The Language class extends the default ExpressionLanguage and represents our Domain Specific Language.

<?php

namespace MyProject\Price;

use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

final class Language extends ExpressionLanguage
{
    protected function registerFunctions()
    {
        // Registering our 'date' function
        $this->register('date', function ($date) {
            return sprintf('(new \DateTime(%s))', $date);
        }, function (array $values, $date) {
            return new \DateTime($date);
        });

        // Registering our 'date_modify' function
        $this->register('date_modify', function ($date, $modify) {
            return sprintf('%s->modify(%s)', $date, $modify);
        }, function (array $values, $date, $modify) {
            if (!$date instanceof \DateTime) {
                throw new \RuntimeException('date_modify() expects parameter 1 to be a Date');
            }
            return $date->modify($modify);
        });
    }
}

Building the engine

The pricing engine will make use of our custom Language class to calculate the price of a Product.

<?php

namespace MyProject\Price;

use MyProject\Model\Product;

class Engine
{
    private $language;
    private $discountRules = array();

    /**
     * Creates a new pricing engine.
     *
     * @param Language $language Our custom language
     */
    public function __construct(Language $language)
    {
        $this->language = $language;
    }

    /**
     * Adds a discount rule.
     *
     * @param string $expression The discount expression
     */
    public function addDiscountRule($expression)
    {
        $this->discountRules[] = $expression;
    }

    /**
     * Calculates the product price.
     *
     * @param Product $product The product
     *
     * @return float The price
     */
    public function calculatePrice(Product $product)
    {
        $price = $product->getBasePrice();
        foreach ($this->discountRules as $discountRule) {
            $price -= $price * $this->language->evaluate($discountRule, array('product' => $product));
        }

        return $price;
    }
}

Wrapping up

Now the engine has been developed, we can use it with some examples.

Depending on when you will execute this code, you will get different results due to the rules based on today's date!

<?php

use MyProject\Price\Language;
use MyProject\Price\Engine;
use MyProject\Model\Product;

$language = new Language();
$engine = new Engine($language);

// Adding the rules previously defined
$engine->addDiscountRule("product.getStock() == 1 ? 0.1 : 0");
$engine->addDiscountRule("date('now') >= date('2014-01-20') and date('now') <= date('2014-02-02') ? 0.05 : 0");
$engine->addDiscountRule("date('now') < date_modify(product.getCreationDate(), '+1 year') and product.getStock() < 5 ? 0.5 : 0");

// Creating a new product
$product = new Product();
$product->setStock(10);
$product->setCreationDate(new DateTime());
$product->setBasePrice(100);

// Outputs 100 (no discount)
echo $engine->calculatePrice($product);

// We change the stock to 1
$product->setStock(1);

// Outputs 45 because
// - the first rule is executed => -10% => 90
// - the third rule is executed => -50% => 45
echo $engine->calculatePrice($product);

Conclusion

The system we built is very simplistic as we don't take into account if discounts can be cumulated. In most systems, discounts are ordered (for example taxes are applied at the end). This could be done by creating prioritized groups of rules. Also, our rules are applied to all products, while in real systems they can also be attached to specific products or categories.

In a nutshell, the Symfony2 Expression Language component is really powerful and allows to create custom rule engines by extending it with simple functions defined as closures.