Feb 04, 2015
Table of contents:
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.
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 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.
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.
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.
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 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.
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.
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.
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.
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 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?.
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.