cult3

Creating a PHP Shopping Basket Part 3 - Creating the Product object

Feb 04, 2015

Table of contents:

  1. What is the Product object?
  2. The role of the Product
  3. Creating the Product object
  4. Using the __get() magic method
  5. The Quantity methods
  6. The status methods
  7. The Delivery Charge method
  8. The Collection methods
  9. The Discount method
  10. The Category method
  11. The action method
  12. Conclusion

One of the most important things to think about when modelling a shopping basket in code is how to represent the actual products themselves.

The Product object should encapsulate the logic and data around each item of the basket. This object should be responsible for holding meta data that we might need to record as part of the shopping process.

In today’s tutorial we will be looking at writing the Product object as well as understanding how this very important object fits within the context of the basket and the package as a whole.

What is the Product object?

Before we look at writing the Product object, first I think it is very important to understand what the object is responsible for and how it sits within the context of this package.

First and foremost, the Product object represents the individual products that are in the basket. By using an object we can encapsulate logic and protect the invariants of the real world concept we are attempting to model.

This also means we can enforce business rules around how the products can be interacted with and manipulated.

The Product object should capture the required data of the product that the customer desires to purchase.

The object should also act as a container to hold the data and other objects that will be required to calculate totals and process the order.

This means the object should hold the potential data, not the reconciled data of the product. The Product object has a state, but it is not the object’s responsibility to calculate it’s own totals.

It’s also important to note that the Product object is part of this PHP package and so it is a totally different object from the Product Domain Object you will likely have as part of your application.

The Product object in this package has the sole purpose of representing the product in the basket and so it has nothing to do with the Domain Object of your application.

The role of the Product

The main interface of this package will be through the Basket object. Don’t worry, we haven’t created that object yet as we will be looking at it next week.

The basket will have many products but it is not the responsibility of the basket to hold data about each of the products. Instead we can represent each product with it’s own dedicated Product object.

Whenever we add, update or remove a product from the basket we will be creating, modifying or removing an instance of Product from the Collection class we created last week in Creating a PHP Shopping Basket Part 2 - Working with Collections.

We therefore have a clear breakdown of responsibilities:

The Basket object is the container and main interface to the package.

The Product object holds data about each product that is in the basket.

The Collection is responsible for managing the list of products in the basket.

Creating the Product object

So hopefully the lines of responsibility between the Product object and the Basket object will be clear.

Now we can start to look at creating the Product object:

<?php namespace PhilipBrown\Basket;

class Product
{
}

The Product object is just a simple plain PHP object that does not need to extend any abstract classes or implement any interfaces.

So the first thing we need to do is to write the __construct() method and set up the object’s default class properties:

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

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

/**
 * @var Money
 */
private $price;

/**
 * @var TaxRate
 */
private $rate;

/**
 * @var int
 */
private $quantity;

/**
 * @var bool
 */
private $freebie;

/**
 * @var bool
 */
private $taxable;

/**
 * @var Money
 */
private $delivery;

/**
 * @var Collection
 */
private $coupons;

/**
 * @var Collection
 */
private $tags;

/**
 * @var Discount
 */
private $discount;

/**
 * @var Category
 */
private $category;

/**
 * Create a new Product
 *
 * @param string $sku
 * @param string $name
 * @param Money $price
 * @param TaxRate $rate
 * @return void
 */
public function __construct($sku, $name, Money $price, TaxRate $rate)
{
    $this->sku = $sku;
    $this->name = $name;
    $this->price = $price;
    $this->rate = $rate;
    $this->quantity = 1;
    $this->freebie = false;
    $this->taxable = true;
    $this->delivery = new Money(0, $price->getCurrency());
    $this->coupons = new Collection;
    $this->tags = new Collection;
}

In order to instantiate a new Product object we need to pass it a $sku, $name, Money $price and TaxRate $rate.

The $sku is the Stock keeping unit or unique id of the product within the context of the application and the $name is simply the name of the product.

The $price should be an instance of Money\Money and the $rate should be an instance of TaxRate. We covered the importance of representing money as Value Objects and the complexity of international tax rates in Creating a PHP Shopping Basket Part 1 - Money, Currency and Taxes.

The rest of the properties of the Product object are set to sensible default values.

Firstly we set the $quantity to 1. If more than one of the same product is ordered we can simply increase this quantity rather than adding multiple objects.

Next we set the status for $freebie and $taxable. By default a Product is not set to free and it should incur tax.

Next we set the default $delivery value as a zero Money value of the correct currency based upon the $price.

And finally we instantiate two new instances of Collection for the product’s $coupons and $tags.

Don’t worry if you are unsure about some of these properties, we will be covering each of them later in this article.

We can also create the test file to test the class as we are writing it:

<?php namespace PhilipBrown\Basket\Tests;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\TaxRates\UnitedKingdomValueAddedTax;

class ProductTest extends \PHPUnit_Framework_TestCase
{
    /** @var Product */
    private $product;

    public function setUp()
    {
        $sku = "1";
        $name = "Four Steps to the Epiphany";
        $rate = new UnitedKingdomValueAddedTax();
        $price = new Money(1000, new Currency("GBP"));
        $this->product = new Product($sku, $name, $price, $rate);
    }
}

In the test file above I’m creating a new instance of Product in the setUp() method so it is available for each test.

Using the __get() magic method

The Product object is very important and so I want to be able to restrict access to it’s internal properties. All of the class properties are private so by default they will not be accessible from outside of the object.

However, I do still want to access the properties, I just don’t want them to editable.

To do this we can use PHP’s magic __get() magic method (What are PHP Magic Methods?).

We can add the __get() magic method to the Product class like this:

/**
 * Get the private attributes
 *
 * @param string $key
 * @return mixed
 */
public function __get($key)
{
    if (property_exists($this, $key)) {
        return $this->$key;
    }
}

This method will automatically be invoked when you attempt to access a class property that is not publicly accessible. First we check to see if the property does exist on the class. If the property does exist, we can simply return it’s value.

We can test that this magic method is working correctly with the following tests:

/** @test */
public function should_return_the_sku()
{
    $this->assertEquals('1', $this->product->sku);
}

/** @test */
public function should_return_the_name()
{
    $this->assertEquals('Four Steps to the Epiphany', $this->product->name);
}

/** @test */
public function should_return_the_price()
{
    $this->assertEquals(new Money(1000, new Currency('GBP')), $this->product->price);
}

/** @test */
public function should_return_the_rate()
{
    $this->assertEquals(new UnitedKingdomValueAddedTax, $this->product->rate);
}

/** @test */
public function should_return_the_quantity()
{
    $this->assertEquals(1, $this->product->quantity);
}

In each of these tests I’m simply asserting that the correct value is being returned from the class property.

By setting the properties as private, but then making them available through the __get() magic method, we can affectively make the properties read-only.

The Quantity methods

With the class properties and the __get() magic method in place we can start to work through the methods of the Product object.

The first set of methods we will look at will be to manipulate the quantity of the Product:

/**
 * Set the quantity
 *
 * @param int $quantity
 * @return void
 */
public function quantity($quantity)
{
    $this->quantity = $quantity;
}

/**
 * Increment the quantity
 *
 * @return void
 */
public function increment()
{
    $this->quantity++;
}

/**
 * Decrement the quantity
 *
 * @return void
 */
public function decrement()
{
    $this->quantity-;
}

The quantity() method allows you to specify an exact quantity by passing an integer value as an argument. The increment() and decrement() methods allow you to increase or decrease the current quantity value by 1.

The tests for these methods are fairly simple:

/** @test */
public function should_increment_the_quantity()
{
    $this->product->increment();

    $this->assertEquals(2, $this->product->quantity);
}

/** @test */
public function should_decrement_the_quantity()
{
    $this->product->decrement();

    $this->assertEquals(0, $this->product->quantity);
}

/** @test */
public function should_set_the_quantity()
{
    $this->product->quantity(5);

    $this->assertEquals(5, $this->product->quantity);
}

In each of these test I’m altering the quantity using the respective method and then asserting that the quantity value is correct.

The status methods

The next methods we will implement are for the Product object’s freebie and taxable status. Both of these properties are boolean values that will affect how the product is reconciled.

For example, if the product is a freebie, then we don’t want to include it’s value in our reconciliation process for the basket’s total. On the other hand, if the product is not taxable, we don’t want to include tax as part of the reconciliation process.

Here are the methods for these two status properties:

/**
 * Set the freebie status
 *
 * @param bool $status
 * @return void
 */
public function freebie($status)
{
    $this->freebie = $status;
}

/**
 * Set the taxable status
 *
 * @param bool $status
 * @return void
 */
public function taxable($status)
{
    $this->taxable = $status;
}

Here are the tests for the two status methods:

/** @test */
public function should_return_the_freebie_status()
{
    $this->assertFalse($this->product->freebie);
}

/** @test */
public function should_set_the_freebie_status()
{
    $this->product->freebie(true);

$this->assertTrue($this->product->freebie);
}

/** @test */
public function should_return_the_taxable_status()
{
    $this->assertTrue($this->product->taxable);
}

/** @test */
public function should_set_the_taxable_status()
{
    $this->product->taxable(false);

    $this->assertFalse($this->product->taxable);
}

First we test that the default value is being returned correctly using the __get() magic method from earlier.

Next we change the default value by passing a boolean value to the appropriate method. We can then assert that the value has changed.

The Delivery Charge method

If a product has a delivery charge we need to include this during the reconciliation process. We can use the following method to add a delivery charge to a Product object:

/**
 * Set the delivery charge
 *
 * @param Money $cost
 * @return void
 */
public function delivery(Money $delivery)
{
    if ($this->price->isSameCurrency($delivery)) {
        $this->delivery = $delivery;
    }
}

First we type hint for an instance of Money to ensure only the correct object type is passed as an argument.

Next we use the isSameCurrency() method on the $price object to ensure that the delivery charge is of the same currency.

Finally, if the currency is correct, we can set the value on the object.

The tests for this method look like this:

/** @test */
public function should_return_the_delivery_charge()
{
    $this->assertInstanceOf('Money\Money', $this->product->delivery);
}

/** @test */
public function should_set_delivery_charge()
{
    $delivery = new Money(100, new Currency('GBP'));

    $this->product->delivery($delivery);

    $this->assertEquals($delivery, $this->product->delivery);
}

First we ensure that the $delivery property is being returned correctly by the __get() magic method.

Next we assert that a delivery value is getting set correctly by setting a new value and then asserting that the value is correct.

The Collection methods

When we wrote the __construct() method of the class, we instantiated two new instances of Collection for $coupons and $tags.

The $coupons property allows you to record any special discount coupons that should be attributed to this product during the transaction.

The $tags property allows you to record any special data about the product during the transaction. For example, if this transaction was prompted by an online marketing campaign.

These two methods look like this:

/**
 * Add a coupon
 *
 * @param string $coupon
 * @return void
 */
public function coupons($coupon)
{
    $this->coupons->push($coupon);
}

/**
 * Add a tag
 *
 * @param string $tag
 * @return void
 */
public function tags($tag)
{
    $this->tags->push($tag);
}

In both of these method we don’t care about setting a key for the item in the collection so we can simply use the push() method on the Collection object.

The tests for these two methods are as follows:

/** @test */
public function should_return_the_coupons_collection()
{
    $this->assertInstanceOf('PhilipBrown\Basket\Collection', $this->product->coupons);
}

/** @test */
public function should_add_a_coupon()
{
    $this->product->coupons('FREE99');

    $this->assertEquals(1, $this->product->coupons->count());
}

/** @test */
public function should_return_the_tags_collection()
{
    $this->assertInstanceOf('PhilipBrown\Basket\Collection', $this->product->tags);
}

/** @test */
public function should_add_a_tag()
{
    $this->product->tags('campaign_123456');

    $this->assertEquals(1, $this->product->tags->count());
}

Once again we first assert that the correct value is returned from the class property. In this case it should be an instance of the Collection object.

Next we add the new coupon and new tag and then assert that the count of the collection is correct.

The Discount method

A very important part of Ecommerce applications and the shopping process in general is the functionality to deal with discounts. However, dealing with discounts isn’t quite as straightforward as it first seems.

When you add a discount to a product, you don’t want to immediately alter the price that is stored on the product object. The full price of the product is something that you still need to record for accountancy purposes or for displaying on an invoice.

Instead we need a way to add a discount so that the total can be calculated during the reconciliation process.

In my experience there are basically two types of discount, either a percentage of the price (e.g 10% off) or as a value ($10 off).

However, we don’t want to limit discounts to these two basic types. Developers who consume this package in their own application might have a unique requirement. We don’t want to force them to open a pull request or hack around with the internal logic because of this one tiny little problem.

Instead we can define an interface to allow a developer to define her own type of discount. During the reconciliation process, it doesn’t matter how the discount is calculated because all discount objects will use the same interface.

So the first thing we can do is define the Discount interface:

<?php namespace PhilipBrown\Basket;

interface Discount
{
    /**
     * Calculate the discount on a Product
     *
     * @param Product
     * @return Money\Money
     */
    public function product(Product $product);

    /**
     * Return the rate of the Discount
     *
     * @return mixed
     */
    public function rate();
}

Next we can write the implementations for PercentageDiscount and ValueDiscount.

First the ValueDiscount class:

<?php namespace PhilipBrown\Basket\Discounts;

use Money\Money;
use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\Discount;
use PhilipBrown\Basket\Money as MoneyInterface;

class ValueDiscount implements Discount, MoneyInterface
{
    /**
     * @var Money
     */
    private $rate;

    /**
     * Create a new Discount
     *
     * @param Money $rate
     * @return void
     */
    public function __construct(Money $rate)
    {
        $this->rate = $rate;
    }

    /**
     * Calculate the discount on a Product
     *
     * @param Product
     * @return Money\Money
     */
    public function product(Product $product)
    {
        return $this->rate;
    }

    /**
     * Return the rate of the Discount
     *
     * @return mixed
     */
    public function rate()
    {
        return $this->rate;
    }

    /**
     * Return the object as an instance of Money
     *
     * @return Money
     */
    public function toMoney()
    {
        return $this->rate;
    }
}

And we can use the following test class to assert everything is working as it should:

<?php namespace PhilipBrown\Basket\Tests\Discounts;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\Discounts\ValueDiscount;
use PhilipBrown\Basket\TaxRates\UnitedKingdomValueAddedTax;

class ValueDiscountTest extends \PHPUnit_Framework_TestCase
{
    /** @var Product */
    private $product;

    public function setUp()
    {
        $sku = "1";
        $name = "iPhone 6";
        $rate = new UnitedKingdomValueAddedTax();
        $price = new Money(60000, new Currency("GBP"));
        $this->product = new Product($sku, $name, $price, $rate);
    }

    /** @test */
    public function should_get_value_discount()
    {
        $amount = new Money(200, new Currency("GBP"));
        $discount = new ValueDiscount($amount);
        $value = $discount->product($this->product);

        $this->assertInstanceOf("Money\Money", $value);
        $this->assertEquals($amount, $value);
        $this->assertEquals($amount, $discount->rate());
    }
}

And the PercentageDiscount class looks like this:

<?php namespace PhilipBrown\Basket\Discounts;

use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\Percent;
use PhilipBrown\Basket\Discount;
use PhilipBrown\Basket\Percentage;

class PercentageDiscount implements Discount, Percentage
{
    /**
     * @var int
     */
    private $rate;

    /**
     * Create a new Discount
     *
     * @param int $rate
     * @return void
     */
    public function __construct($rate)
    {
        $this->rate = $rate;
    }

    /**
     * Calculate the discount on a Product
     *
     * @param Product
     * @return Money\Money
     */
    public function product(Product $product)
    {
        return $product->price->multiply($this->rate / 100);
    }

    /**
     * Return the rate of the Discount
     *
     * @return mixed
     */
    public function rate()
    {
        return new Percent($this->rate);
    }

    /**
     * Return the object as a Percent
     *
     * @return Percent
     */
    public function toPercent()
    {
        return new Percent($this->rate);
    }
}

And once again we can test this class with the following tests:

<?php namespace PhilipBrown\Basket\Tests\Discounts;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Percent;
use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\Discounts\PercentageDiscount;
use PhilipBrown\Basket\TaxRates\UnitedKingdomValueAddedTax;

class PercentageDiscountTest extends \PHPUnit_Framework_TestCase
{
    /** @var Product */
    private $product;

    public function setUp()
    {
        $sku = "1";
        $name = "iPhone 6";
        $rate = new UnitedKingdomValueAddedTax();
        $price = new Money(60000, new Currency("GBP"));
        $this->product = new Product($sku, $name, $price, $rate);
    }

    /** @test */
    public function should_get_value_discount()
    {
        $discount = new PercentageDiscount(20);
        $value = $discount->product($this->product);

        $this->assertInstanceOf("Money\Money", $value);
        $this->assertEquals(new Money(12000, new Currency("GBP")), $value);
        $this->assertEquals(new Percent(20), $discount->rate());
    }
}

In both of these test classes I’m simply creating the discount and then asserting that the correct values and rates are returned.

You will also notice that I’m using an interface called MoneyInterface on the ValueDiscount class and a class called Percent in the PercentageDiscount class. Don’t worry about these for now, as their purpose will become clear in a future tutorial.

With the discount classes in place, we can now add the discount() method to the Product object:

/**
 * Set a discount
 *
 * @param Discount $discount
 * @return void
 */
public function discount(Discount $discount)
{
    $this->discount = $discount;
}

This method accepts an instance of an object that implements the Discount interface. This means a developer can write their own discount implementation.

We can also add the following test:

/** @test */
public function should_add_discount()
{
    $this->product->discount(new PercentageDiscount(20));

    $this->assertInstanceOf(
    'PhilipBrown\Basket\Discounts\PercentageDiscount', $this->product->discount);
}

It is not the responsibility of the Product object to do anything with the Discount object, so this tests simply needs to assert that the PercentageDiscount object is set correctly on the object.

The Category method

In a typical Ecommerce application, products of a certain type will all have the same properties. For example, in certain countries, physical books or children’s clothes do not have tax.

Instead of repeating this same logic, we can encapsulate it as an object that can apply those rules as a process.

This means the product can be assigned to the category, and those properties will automatically be applied during reconciliation.

As with discounts, we want to enable any developer to write their own category to satisfy their application’s requirements. To do this we can write an interface:

<?php namespace PhilipBrown\Basket;

interface Category
{
    /**
     * Categorise a Product
     *
     * @param Product $product
     * @return void
     */
    public function categorise(Product $product);
}

With the interface in place, we can write our first implementation:

<?php namespace PhilipBrown\Basket\Categories;

use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\Category;

class PhysicalBook implements Category
{
    /**
     * Categorise a Product
     *
     * @param Product $product
     * @return void
     */
    public function categorise(Product $product)
    {
        $product->taxable(false);
    }
}

Whenever a product is categorised as a PhysicalBook the product will automatically be set as not taxable. This is a simple example, but being able to formulate these categories will enable you to encapsulate important business rules of the application.

We can test the PhysicalBook class with the following test class:

<?php namespace PhilipBrown\Basket\Tests\Categories;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Product;
use PhilipBrown\Basket\Categories\PhysicalBook;
use PhilipBrown\Basket\TaxRates\UnitedKingdomValueAddedTax;

class PhysicalBookTest extends \PHPUnit_Framework_TestCase
{
    /** @var Product */
    private $product;

    public function setUp()
    {
        $sku = "1";
        $name = "Fooled By Randomness";
        $rate = new UnitedKingdomValueAddedTax();
        $price = new Money(1000, new Currency("GBP"));
        $this->product = new Product($sku, $name, $price, $rate);
    }

    /** @test */
    public function should_categorise_as_physicalbook()
    {
        $category = new PhysicalBook();
        $category->categorise($this->product);

        $this->assertFalse($this->product->taxable);
    }
}

In this test I’m creating a new PhysicalBook object and then asserting that the rules of the category are applied to the product correctly. In this case, the product should be set as not taxable.

Next we can add the categorise() method to the Product object:

/**
 * Set a category
 *
 * @param Category $category
 * @return void
 */
public function category(Category $category)
{
    $this->category = $category;

    $this->category->categorise($this);
}

First we type hint for an object that implements the Category interface. This allows the developer to write their own implementation.

Next we save the Category object to the $this->category class property.

Finally we pass the Product object to the categorise() method so the category’s rules are applied.

We can test this method with the following test:

/** @test */
public function should_categorise_a_product()
{
    $this->product->category(new PhysicalBook);

    $this->assertInstanceOf('PhilipBrown\Basket\Categories\PhysicalBook', $this->product->category);
    $this->assertFalse($this->product->taxable);
}

First we assert that the Category object was successfully saved to the $category class property.

Next we assert that the product is now not taxable according to the rules of the category.

The action method

The final method we are going to implement on the Product object is a method called action() that accepts a Closure:

/**
 * Run a Closure of actions
 *
 * @param Closue $actions
 * @return void
 */
public function action(Closure $actions)
{
    call_user_func($actions, $this);
}

This method will allow the developer to pass a Closure so a series of actions can be applied to the Product. An example of this can be seen in the test file:

/** @test */
public function should_run_closure_of_actions()
{
    $this->product->action(function ($product) {
        $product->quantity(3);
        $product->freebie(true);
        $product->taxable(false);
    });

    $this->assertEquals(3, $this->product->quantity);
    $this->assertTrue($this->product->freebie);
    $this->assertFalse($this->product->taxable);
}

This gives the developer an easy way of applying multiple properties to the Product object.

If you are unfamiliar with Closures, take a look at What are PHP Lambdas and Closures?.

Conclusion

Objects are extremely important aspect of Object Oriented Programming. In this case, the Product object is important for a number of reasons.

Firstly it encapsulates the logic of a product. In the real world we expect products to behave in certain ways. By modelling this as an object we can restrict and define that behaviour so it mirrors the real world.

Secondly, the object allows us to define what is possible and how it can be manipulated using it’s public API of methods.

And thirdly, it allows us to protect the state of the object, it’s the data, and other objects it holds. This is important because the product should not be responsible for calculating it’s own totals during the reconciliation process.

Today’s tutorial was probably a lot to take in. If you would like to see all of the code in context, take a look at the GitHub repository.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.