cult3

Working with Products, Orders and Sales in PHP

Jun 11, 2014

Table of contents:

  1. Description of this package
  2. Technically how will this package work?
  3. The Helper class
  4. Regions
  5. The Merchant factory
  6. The Order class
  7. The Product object
  8. Product actions
  9. The Setter methods
  10. The Getter methods
  11. The Order class
  12. Tests
  13. Conclusion

In last week’s tutorial, we looked at building a package that abstracts a lot of the problems associated with dealing with money and currency in software applications.

The Money package from last week is the basic building block of working with actual money objects and values, but if we were to build a fully fledged ecommerce website, we still need more than just these raw objects.

I think packages like my Money package are best suited to be consumed by other packages. By abstracting the problem of money and currency, we are free to use that building block package inside of another package without having to worry about those implementation details.

In this tutorial I’m going to be building a package to deal with products, orders and sales within a PHP ecommerce web application.

Description of this package

Before I get into the code, first I’ll explain what this package is going to do.

This package will solve the problem of dealing with multiple products during the order and sales process of completing a transaction within an ecommerce application.

Keeping track of multiple products within an ordering process can be difficult because you have to monitor totals, discounts, tax as well as many other variables. Added to that each different region you will support will usually have a different currency, tax rate and rules around which products are taxable or not.

This package will provide an easy to use interface for tracking multiple product orders so you can present these order details to the customer, but also track all of those individual details to aid your accounting system.

This package will consume the Money package from last week so I don’t have to solve that problem twice. The Money package is a dependency of this package, so we don’t need to see it because it will just do the work behind the scenes.

I’m going to skip the part about setting up the structure of the PHP package because I’ve already covered that in this article.

Technically how will this package work?

I’ve described how this package will theoretically work, but I’ll also describe how it will technically work before I get into the weeds of the implementation details.

The Merchant class will act like a factory to create a new order.

When a new Order is created it will be injected with a new Region object that will encapsulate the details of a particular jurisdiction.

The Order object will record the totals of the order and allow you to add, updated or remove products from the order.

Each product of the order will be an instance of Product that will encapsulate all of the details of a particular product.

When a change occurs on the order (such as adding or removing a product), the order will reconcile to keep the totals correct.

The Helper class

The first class that I’m going to write for this package is a Helper class that will provide two convenience methods that I will be using in the other main classes:

<?php namespace PhilipBrown\Merchant;

use Exception;

abstract class Helper
{
    /**
     * Convert a string to camelcase
     *
     * e.g hello_world -> helloWorld
     *
     * @param string $str
     * @return string
     */
    public static function camelise($str)
    {
        return preg_replace_callback(
            "/_([a-z0-9])/",
            function ($m) {
                return strtoupper($m[1]);
            },
            $str
        );
    }

    /**
     * __get Magic Method
     *
     * @return mixed
     */
    public function __get($param)
    {
        $method = "get" . ucfirst(self::camelise($param)) . "Parameter";

        if (method_exists($this, $method)) {
            return $this->{$method}();
        }

        throw new Exception("$param is not a valid property on this object");
    }
}

I will be using this class as an abstract class to extend the other classes from. Arguably, this would be a good opportunity to use a Trait, but I will be covering traits in a future tutorial. I think it is fine to use an abstract class in this case.

This class has two basic methods, one for turning a method name into it’s camalised form, and the __get() magic method to return class properties.

Regions

Next I will create the specific Region classes that will encapsulate details of a particular jurisdiction.

Firstly I will define a RegionInterface:

<?php namespace PhilipBrown\Merchant;

interface RegionInterface
{
    /**
     * Get the name of the region
     *
     * @return string
     */
    public function getNameParameter();

    /**
     * Get the currency of the region
     *
     * @return string
     */
    public function getCurrencyParameter();

    /**
     * Check to see if tax is set in this region
     *
     * @return bool
     */
    public function getTaxParameter();

    /**
     * Get the tax rate of the region
     *
     * @return int
     */
    public function getTaxRateParameter();
}

I will also define an AbstractRegion:

<?php namespace PhilipBrown\Merchant;

abstract class AbstractRegion extends Helper
{
    /**
     * The name of the region
     *
     * @var string
     */
    protected $name;

    /**
     * The currency of the region
     *
     * @var string
     */
    protected $currency;

    /**
     * Does this region have tax?
     *
     * @var bool
     */
    protected $tax;

    /**
     * The tax rate of the region
     *
     * @var integer
     */
    protected $taxRate;

    /**
     * Get the name of the region
     *
     * @return string
     */
    public function getNameParameter()
    {
        return $this->name;
    }

    /**
     * Get the currency of the region
     *
     * @return string
     */
    public function getCurrencyParameter()
    {
        return $this->currency;
    }

    /**
     * Check to see if tax is set in this region
     *
     * @return bool
     */
    public function getTaxParameter()
    {
        return $this->tax;
    }

    /**
     * Get the tax rate of the region
     *
     * @return integer
     */
    public function getTaxRateParameter()
    {
        return $this->taxRate;
    }

    /**
     * Return the region as a string
     *
     * @return string
     */
    public function __toString()
    {
        return $this->getNameParameter();
    }
}

And finally I will define a region for England:

<?php namespace PhilipBrown\Merchant\Region;

use PhilipBrown\Merchant\AbstractRegion;
use PhilipBrown\Merchant\RegionInterface;

class England extends AbstractRegion implements RegionInterface
{
    /**
     * @var string
     */
    protected $name = "England";

    /**
     * @var string
     */
    protected $currency = "GBP";

    /**
     * @var boolean
     */
    protected $tax = true;

    /**
     * @var integer
     */
    protected $taxRate = 20;
}

Now if I wanted to add additional regions, I would just have to create new child classes and add the required details for that jurisdiction.

The Merchant factory

The main entry point to this package will be through the Merchant factory:

<?php namespace PhilipBrown\Merchant;

use PhilipBrown\Merchant\Exception\InvalidRegionException;

class Merchant
{
    /**
     * Create a new Order
     *
     * @param string $region
     * @return PhilipBrown\Merchant\Order
     */
    public static function order($region)
    {
        $class = "PhilipBrown\Merchant\Region\\" . ucfirst($region);

        if (class_exists($class)) {
            return new Order(new $class());
        }

        throw new InvalidRegionException("$region is not a valid region");
    }
}

This class has a single method which accepts a string for the name of the region.

This class will attempt to find the region class by creating a string and using the class_exists() function. If the class does exist we can instantiate a new Order and inject the region.

If the region class could not be found, an InvalidRegionException will be thrown.

This exception class is your basic custom exception:

<?php namespace PhilipBrown\Merchant\Exception;

use Exception;

class InvalidRegionException extends Exception
{
}

The Order class

The majority of the heavy lifting occurs in the Order class:

<?php namespace PhilipBrown\Merchant;

use PhilipBrown\Money\Money;
use PhilipBrown\Merchant\RegionInterface;
use PhilipBrown\Merchant\Exception\InvalidOrderException;

class Order extends Helper
{
}

The first thing I will do it define the __construct() method and set the class properties:

/**
 * The region of the Order
 *
 * @var RegionInterface
 */
protected $region;

/**
 * The products of the order
 *
 * @var array
 */
protected $products;

/**
 * The cache of the products for easy lookups
 *
 * @var array
 */
protected $products_cache;

/**
 * The total value of the order
 *
 * @var Money
 */
protected $total_value;

/**
 * The total discount of the order
 *
 * @var Money
 */
protected $total_discount;

/**
 * The total tax of the order
 *
 * @var Money
 */
protected $total_tax;

/**
 * The subtotal of the order
 *
 * @var Money
 */
protected $subtotal;

/**
 * The total of the order
 *
 * @var Money
 */
protected $total;

/**
 * Does the order need to be reconciled?
 *
 * @var boolean
 */
protected $dirty;

/**
 * Create a new Order
 *
 * @param RegionInterface $region
 * @return void
 */
public function __construct(RegionInterface $region)
{
$this->region = $region;
$this->dirty = true;
}

The class properties should be pretty self explanatory, but if you are unsure of any of them at this stage, don’t worry, I’ll be covering each one as I talk about each method of the class.

The __construct() method accepts an object that implements the RegionInterface.

I’m also setting the $this->dirty class property to true. This property regulates whether the totals need to be reconciled or not. I’ve set it to true by default so the object knows it needs to reconcile before giving totals.

The Product object

Before getting into the individual methods of the Order class, I think it makes sense to create the Product class first:

<?php namespace PhilipBrown\Merchant;

use Closure;
use PhilipBrown\Money\Money;
use PhilipBrown\Merchant\Exception\InvalidProductException;

class Product extends Helper
{
}

The Product class is a Value Object that will encapsulate the details of a particular type of product within the order.

The first thing to do is to define the class properties and the __construct() method:

/**
 * The product's stock keeping unit
 *
 * @var string
 */
protected $sku;

/**
 * The product's currency
 *
 * @var string
 */
protected $currency;

/**
 * The value of the product
 *
 * @var Money
 */
protected $value;

/**
 * Is this product taxable?
 *
 * @var boolean
 */
protected $taxable;

/**
 * The tax rate of the product
 *
 * @var integer
 */
protected $taxRate;

/**
 * The amount of tax
 *
 * @var Money
 */
protected $tax;

/**
 * The value of the discount
 *
 * @var Money
 */
protected $discount;

/**
 * The quantity of the current product
 *
 * @var int
 */
protected $quantity;

/**
 * Is this product a freebie?
 *
 * @var bool
 */
protected $freebie;

/**
 * The coupon that is associated with this product
 *
 * @var string
 */
protected $coupon;

/**
 * Construct
 *
 * @param string $sku
 * @param int $value
 * @param PhilipBrown\Money\Currency $currency
 * @param bool $taxable
 * @param int $taxRate
 * @return void
 */
public function __construct($sku, $value, $currency, $taxable, $taxRate)
{
    $this->sku = $sku;
    $this->currency = $currency;
    $this->value = Money::init($value, $currency);
    $this->taxable = $taxable;
    $this->taxRate = $taxRate;
    $this->discount = Money::init(0, $currency);
    $this->quantity = 1;
    $this->freebie = false;
    $this->tax = $this->calculateTax();
    $this->total = $this->calculateTotal();
}

The $sku, $value, $currency, $taxable and $taxRate will be injected into the class when a new product is created inside the Order.

The remaining class properties are set to default values within the __construct() method.

Finally the $this->tax property is set by the $this->calculateTax() method and the $this->total property is set by the $this->calculateTotal() method.

The $this->calculateTax() method looks like this:

/**
 * Set the total depending on whether this product is a freebie
 *
 * @return Money
 */
protected function calculateTotal()
{
    return $this->total = ($this->freebie) ? Money::init(0, $this->currency) : $this->value;
}

And the $this->calculateTax() method looks like this:

/**
 * Calculate the tax of the product
 *
 * @return Money
 */
protected function calculateTax()
{
    if ($this->taxable) {
        $total = $this->value->subtract($this->discount);

        return $total->multiply($this->taxRate / 100);
    }

    return Money::init(0, $this->currency);
}

These are just helper methods that will be used to set totals throughout this class. You’ll notice that I’m not multiplying these values by the $quantity. This Value Object just holds details of the product, those calculations are performed in the Order class.

Product actions

When a new product is added to an order, there are a couple of actions a user will want to take. For example, the product might have a discount code, or this particular product might not be taxable.

In order to allow the user to specify these details, I want to be able to accept either an Array or a Closure of actions to perform.

The syntax of this code would be as follows:

// Using an array
$order->add("456", 1000, [
    "discount" => 200,
    "coupon" => "SUMMERSALE2014",
]);

// Using a closure
$order->add("789", 1000, function ($item) {
    $item->taxable(false);
    $item->quantity(10);
});

The action is the third parameter of the add() method on the Order class and should accept either an Array or a Closure. This action will then be passed on to the following method on the Product object to be actioned:

/**
 * Accept an array or a Closure of actions to run on the product
 *
 * @param Closure|array $action
 * @return bool
 */
public function action($action)
{
    if (is_array($action)) {
        return $this->runActionArray($action);
    }

    if ($action instanceof Closure) {
        return $this->runActionClosure($action);
    }

    throw new InvalidProductException('The action must be an array or a closure');
}

This method will simply determine what type of action has been passed as an argument and then delegate it to the specific method to action it.

If the action is not an Array or a Closure an InvalidProductException exception will be thrown. Again this is just a simple exception class:

<?php namespace PhilipBrown\Merchant\Exception;

use Exception;

class InvalidProductException extends Exception
{
}

The runActionArray() and runActionClosure() are as follows:

/**
 * Run an array of actions
 *
 * @param array $action
 * @return bool
 */
protected function runActionArray(array $action)
{
    foreach ($action as $k => $v) {
        $method = 'set'.ucfirst(self::camelise($k)).'Parameter';

        if (method_exists($this, $method)) $this->{$method}($v);
    }

    return true;
}

/**
 * Run a Closure of actions
 *
 * @param Closure $action
 * @return bool
 */
protected function runActionClosure($action)
{
    call_user_func($action, $this);

    return true;
}

The runActionClosure() method will spin through each key and value of the array and attempt to run the specific method if it has been defined.

The runActionClosure() method will run the Closure and pass in an instance of itself as the only argument.

The Setter methods

Next we need to define setter methods that will allow us to manipulate the properties of the class. Each setter will have a friendly helper method that will delegate to the actual method that will perform the action. The friendly helper method is used as a nicer syntax when running inside of the Closure action.

The Quantity methods are:

/**
 * Quantity helper method
 *
 * @param integer $value
 * @return void
 */
public function quantity($value)
{
    $this->setQuantityParameter($value);
}

/**
 * Set the quantity parameter
 *
 * @param int $value
 * @return int
 */
protected function setQuantityParameter($value)
{
    if (is_int($value)) {
        return $this->quantity = $value;
    }

    throw new InvalidProductException('The quantity property must be an integer');
}

The Taxable methods are:

/**
 * Taxable helper method
 *
 * @param boolean $value
 * @return void
 */
public function taxable($value)
{
    $this->setTaxableParameter($value);
}

/**
 * Set the taxable parameter
 *
 * @param boolean $value
 * @return Money
 */
protected function setTaxableParameter($value)
{
    if (is_bool($value)) {
        $this->taxable = $value;
        return $this->tax = $this->calculateTax();
    }

    throw new InvalidProductException('The taxable property must be a boolean');
}

The Discount methods are:

/**
 * Discount helper method
 *
 * @param integer $value
 * @return void
 */
public function discount($value)
{
    $this->setDiscountParameter($value);
}

/**
 * Set the discount parameter
 *
 * @param integer $value
 */
protected function setDiscountParameter($value)
{
    if (is_int($value)) {
        $this->discount = Money::init($value, $this->currency);
        return $this->tax = $this->calculateTax();
    }

    throw new InvalidProductException('The discount property must be an integer');
}

The Freebie methods are:

/**
 * Freebie helper method
 *
 * @param boolean $value
 * @return void
 */
public function freebie($value)
{
    $this->setFreebieParameter($value);
}

/**
 * Set the freebie parameter
 *
 * @param boolean $value
 */
protected function setFreebieParameter($value)
{
    if (is_bool($value)) {
        $this->freebie = $value;
        $this->taxable = ($this->freebie) ? false : $this->taxable;
        $this->tax = ($this->freebie) ? Money::init(0, $this->currency) : $this->calculateTax();
        $this->discount = ($this->freebie) ? Money::init(0, $this->currency) : $this->discount;
        return $this->calculateTotal();
    }

    throw new InvalidProductException('The freebie property must be a boolean');
}

And the Coupon methods are:

/**
 * Coupon helper method
 *
 * @param string $value
 */
public function coupon($value)
{
    $this->setCouponParameter($value);
}

/**
 * Set the coupon parameter
 *
 * @param string $value
 */
protected function setCouponParameter($value)
{
    if (is_string($value)) {
        return $this->coupon = $value;
    }

    throw new InvalidProductException('The coupon property must be a string');
}

Each of these methods do a little bit of type hinting and will throw an exception if the right argument type has not been provided.

A couple of the methods also need to reset some of the totals depending on the argument that was provided. That is why we have the calculateTax() and calculateTotal() methods from earlier.

The Getter methods

Finally we have a number of getter methods that will return the value of a particular class property:

/**
 * Get the sku parameter
 *
 * @return string
 */
protected function getSkuParameter()
{
    return $this->sku;
}

/**
 * Get currency parameter
 *
 * @return string
 */
protected function getCurrencyParameter()
{
    return $this->currency;
}

/**
 * Get the value parameter
 *
 * @return integer
 */
protected function getValueParameter()
{
    return $this->value;
}

/**
 * Get the taxable parameter
 *
 * @return boolean
 */
protected function getTaxableParameter()
{
    return $this->taxable;
}

/**
 * Get the tax rate parameter
 *
 * @return integer
 */
protected function getTaxRateParameter()
{
    return $this->taxRate;
}

/**
 * Get the tax parameter
 *
 * @return Money
 */
protected function getTaxParameter()
{
    return $this->tax;
}

/**
 * Get the quantity parameter
 *
 * @return integer
 */
protected function getQuantityParameter()
{
    return $this->quantity;
}

/**
 * Get the discount parameter
 *
 * @return Money
 */
protected function getDiscountParameter()
{
    return $this->discount;
}

/**
 * Get the freebie parameter
 *
 * @return boolean
 */
protected function getFreebieParameter()
{
    return $this->freebie;
}

/**
 * Get the coupon parameter
 *
 * @return string
 */
protected function getCouponParameter()
{
    return $this->coupon;
}

The Order class

Right! Now that we’ve created the Product class we can start to implement the main methods of the Order class.

The Add method

The first method we’ll look at is the add() method:

/**
 * Add a product to the order
 *
 * @param string $sku
 * @param integer $value
 * @param Closure|array $action
 * @return boolean
 */
public function add($sku, $value, $action = null)
{
    $product = new Product(
        $sku,
        $value,
        $this->region->currency,
        $this->region->tax,
        $this->region->taxRate
    );

    if (!is_null($action)) $product->action($action);

    $this->products[] = $product;

    $this->products_cache[] = $sku;

    $this->dirty = true;

    return true;
}

The add() method accepts a $sku, a $value and an optional $action. The SKU is just the application’s internal reference for that product.

We can then create a new instance of Product and pass in the required details of the $sku, the $value and a couple of details from the region.

If the $action has been set, we can pass the $action to the action() method on the Product instance.

Next we can add the $product to the array of $products as well as the $product_cache. The cache is just a quicker way of finding a product by it’s $sku.

And finally we can set $this->dirty to be true so the Order knows it must reconcile the totals again.

The Remove method

The remove method will allow you to completely remove a product from the order:

/**
 * Remove a product from the order
 *
 * @param string $sku
 * @return boolean
 */
public function remove($sku)
{
    if (in_array($sku, $this->products_cache)) {
        $key = array_search($sku, $this->products_cache);

        unset($this->products[$key]);
        unset($this->products_cache[$key]);

        $this->products = array_values($this->products);
        $this->products_cache = array_values($this->products_cache);

        $this->dirty = true;

        return true;
    }

    throw new InvalidOrderException("$sku was not found in the products list");
}

The remove() method accepts the product’s $sku as an argument.

Firstly we check to see if the $sku is in the $products_cache. If it is not we can throw an InvalidOrderException. Again, this is just a simple custom exception the same as the others:

<?php namespace PhilipBrown\Merchant\Exception;

use Exception;

class InvalidOrderException extends Exception
{
}

If the product is part of the order we can retrieve the key and unset the product from the $this->products array and the $this->products_cache. Next we can reset the values of each of the arrays by calling the array_values function.

And finally we can set $this->dirty to be true to force the reconciliation.

The Update method

The update() method is basically the same as the add() method, but before adding the product, first we’ll completely remove the existing product:

/**
 * Update an existing product
 *
 * @param string $sku
 * @param integer $value
 * @param Closure|array $action
 */
public function update($sku, $value, $action = null)
{
    if ($this->remove($sku)) {
        $product = new Product(
        $sku,
        $value,
        $this->region->currency,
        $this->region->tax,
        $this->region->taxRate
        );

        if (!is_null($action)) $product->action($action);

        $this->products[] = $product;

        $this->products_cache[] = $sku;

        $this->dirty = true;

        return true;
    }
}

The Reconcile method

The reconcile() method will be called before any totals are returned from the order:

/**
 * Reconcile the order if it is dirty
 *
 * @return void
 */
public function reconcile()
{
    if ($this->dirty) {
        $total_value = 0;
        $total_discount = 0;
        $total_tax = 0;
        $subtotal = 0;

        foreach ($this->products as $product) {
            $i = $product->quantity;

            while($i > 0)
            {
                $total_value = $total_value + $product->value->cents;
                $total_discount = $total_discount + $product->discount->cents;
                $total_tax = $total_tax + $product->tax->cents;
                $subtotal = $subtotal + $product->total->cents;
                $i-;
            }
        }

        $this->total_value = Money::init($total_value, $this->region->currency);
        $this->total_discount = Money::init($total_discount, $this->region->currency);
        $this->total_tax = Money::init($total_tax, $this->region->currency);
        $this->subtotal = Money::init(($subtotal), $this->region->currency);
        $this->total = Money::init(($subtotal - $this->total_discount->cents + $this->total_tax->cents), $this->region->currency);

        $this->dirty = false;
    }
}

First we can check to see if the order is dirty. If the order is not dirty we can just skip the reconciliation process.

Next we will set the totals to a default of 0 and then loop through each of the products and add the values.

Finally we can create the totals as instances of Money with the correct currency taken from the $this->region and set $this->dirty to be false.

The Getter methods

For each of the totals, the getter method will first run the reconcile() method to ensure the totals are up-to-date:

/**
 * Get the total parameter
 *
 * @return Money
 */
protected function getTotalValueParameter()
{
    $this->reconcile();

    return $this->total_value;
}

/**
 * Get the total discount parameter
 *
 * @return Money
 */
protected function getTotalDiscountParameter()
{
    $this->reconcile();

    return $this->total_discount;
}

/**
 * Get the total tax parameter
 *
 * @return Money
 */
protected function getTotalTaxParameter()
{
    $this->reconcile();

    return $this->total_tax;
}

/**
 * Get the subtotal parameter
 *
 * @return Money
 */
protected function getSubtotalParameter()
{
    $this->reconcile();

    return $this->subtotal;
}

/**
 * Get the total parameter
 *
 * @return Money
 */
protected function getTotalParameter()
{
    $this->reconcile();

    return $this->total;
}

I will also provide a couple of getter methods to return the $this->region instance and the array of $this->products.

/**
 * Get the region parameter
 *
 * @return string
 */
protected function getRegionParameter()
{
    return $this->region;
}

/**
 * Get the products parameter
 *
 * @return array
 */
protected function getProductsParameter()
{
    return $this->products;
}

Tests

Tests are an important part of a package like this because if we make a change to the code, we want to know that we haven’t accidentally broken another aspect. Shipping a broken package like this into your production code could be nasty.

Merchant tests

First we’ll check the Merchant class:

use PhilipBrown\Merchant\Merchant;

class MerchantTest extends PHPUnit_Framework_TestCase
{
    /**
     * @expectedException PhilipBrown\Merchant\Exception\InvalidRegionException
     * @expectedExceptionMessage Nuh huh is not a valid region
     */
    public function testExceptionOnInvalidRegion()
    {
        $m = new Merchant();
        $m->order("Nuh huh");
    }

    public function testInstantiateValidRegion()
    {
        $o = Merchant::order("England");
        $this->assertInstanceOf("PhilipBrown\Merchant\Order", $o);
        $this->assertInstanceOf(
            "PhilipBrown\Merchant\RegionInterface",
            $o->region
        );
        $this->assertEquals("England", $o->region);
        $this->assertEquals("GBP", $o->region->currency);
        $this->assertTrue($o->region->tax);
        $this->assertEquals(20, $o->region->tax_rate);
    }
}

Here I’m just checking that only an order can only be created with a valid region and the basic Order API is working correctly for returning information about the current region.

Product tests

To test the Product class I’ve just written tests to cover as many different scenarios as I can think of. Here I’m basically just setting up the order and asserting that everything looks correct:

public function testAddProductWithActionArrayAndQuantityProperty()
{
    $o = Merchant::order('England');
    $o->add('123', 1000, array(
    'quantity' => 2
    ));

    $this->assertEquals(2, $o->products[0]->quantity);
}

I’m also testing that the correct exceptions are triggered:

/**
 * @expectedException PhilipBrown\Merchant\Exception\InvalidProductException
 * @expectedExceptionMessage The action must be an array or a closure
 */
public function testInvalidActionTypeException()
{
    $o = Merchant::order('England');
    $o->add('123', 233, 'action');
}

To see the full range of Product tests, click here.

Order tests

And finally, to test the Order class I once again set up various scenarios and assert that the totals are as I would expect them to be:

public function testCorrectSubtotalProperty()
{
    $o = Merchant::order('England');
    $o->add('123', 1000);
    $o->add('456', 2000, array(
    'discount' => 500
    ));

    $o->add('789', 600, array(
    'freebie' => true
    ));

    $this->assertEquals(3000, $o->subtotal->cents);
}

To see the full range of Order tests, click here.

Conclusion

Phew! That was a long article! Well done if you made it this far.

Hopefully if you are building an ecommerce application you will find this package really helpful for building orders and tracking the various totals that you need to monitor and record when a customer buys something from you. If you want to use this package in one of your projects, the source code is available on GitHub.

But even if you aren’t building an ecommerce application right now, hopefully you will be able to take something away from this approach.

Did you notice that I didn’t once have to think about dealing with money values, currencies, rounding errors or ensuring that my money values were value objects? It was as if PHP had native money object support!

I think the most important thing to take away from this tutorial is, when we abstract a problem like money and currency into it’s own package, that package can then easily be consumed by other packages.

By building those basic foundational blocks, we can consume and build even better packages and applications without ever having to worry about those underlying implementation details.

So the next time you face a problem like money and currency, think of a way of abstracting the problem into it’s own, self contained package that then can be consumed by many other packages. Solving the problem once is a beautiful way of dealing with problems that crop up again and again.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.