Introduction
This is the second part of my "From Procedural to OO" series. In the first article, I refactored procedural PHP into OO using Strategy, Factory, and Repository patterns. Now I'm taking the next step: building an Order system that handles multiple products.
The Challenge
A real e-commerce needs to handle multiple products in a single order, each with its own quantity. The total must include all products, shipping costs, and optional discounts.
OrderItem
OrderItem is responsible for storing a product and its quantity. It also knows how to calculate its own subtotal and weight.
class OrderItem
{
public function __construct(
private Product $product,
private int $quantity
) {}
public function getSubTotal(): float
{
return $this->product->price * $this->quantity;
}
public function getWeight(): float
{
return $this->product->weight * $this->quantity;
}
}
Order
The Order class receives an item list, a ShippingCalculatorInterface, and an optional DiscountInterface. It is responsible for calculating the final value paid by the customer.
class Order
{
public function __construct(
private array $items,
private ShippingCalculatorInterface $shippingCalculator,
private ?DiscountInterface $discount = null
) {}
public function getProductsTotal(): float
{
$total = 0;
foreach ($this->items as $item) {
$total += $item->getSubTotal();
}
return $total;
}
public function getTotalWeight(): float
{
$weight = 0;
foreach ($this->items as $item) {
$weight += $item->getWeight();
}
return $weight;
}
public function getTotal(): float
{
$total = $this->getProductsTotal();
$total += $this->shippingCalculator->calculate($this->getTotalWeight());
if ($this->discount) {
$total = $this->discount->apply($total);
}
return $total;
}
}
Order receives interfaces instead of concrete classes to avoid high coupling. This way, I can use any shipping calculator or discount type without changing the Order class.
OrderController
The controller is responsible for handling requests to create an order. It receives factories to create the shipping calculator based on the user's choice, and repositories to verify if products and coupons exist.
class OrderController
{
public function __construct(
private ProductRepositoryInterface $productRepository,
private ShippingCalculatorFactory $shippingFactory,
private DiscountFactory $discountFactory,
private CouponRepositoryInterface $couponRepository
) {}
public function checkout(array $itemsData, string $shippingType, ?string $couponCode = null): float
{
$items = [];
foreach ($itemsData as $data) {
$product = $this->productRepository->findById($data['productId']);
$items[] = new OrderItem($product, $data['quantity']);
}
$shippingCalculator = $this->shippingFactory->create($shippingType);
$discount = null;
if ($couponCode) {
$coupon = $this->couponRepository->findByCode($couponCode);
$discount = $this->discountFactory->create($coupon->type, $coupon->value);
}
$order = new Order($items, $shippingCalculator, $discount);
return $order->getTotal();
}
}
The controller orchestrates - it fetches data, creates objects, and passes them to Order. The Order calculates - it knows how to compute the total. Each one has a single responsibility.
Testing
I can test the Order system without a real database using InMemoryRepository:
function testOrderCheckout(): void
{
$productRepository = new InMemoryProductRepository();
$productRepository->save(new Product(1, 'Notebook', 2500.00, 10, 2.0));
$couponRepository = new InMemoryCouponRepository();
$couponRepository->save(new Coupon('DISCOUNT10', 'percentage', 10.0, null));
$shippingFactory = new ShippingCalculatorFactory();
$discountFactory = new DiscountFactory();
$controller = new OrderController(
$productRepository,
$shippingFactory,
$discountFactory,
$couponRepository
);
$itemsData = [
['productId' => 1, 'quantity' => 2]
];
$total = $controller->checkout($itemsData, 'transport', 'DISCOUNT10');
// Product: 2500 * 2 = 5000
// Weight: 2kg * 2 = 4kg
// Shipping: (4 * 1.8) + 10 = 17.20
// Subtotal: 5017.20
// Discount 10%: 4515.48
if (round($total, 2) !== 4515.48) {
throw new Exception("Test failed: expected 4515.48, got $total");
}
echo "Test passed!\n";
}
Conclusion
With Order and OrderItem, the e-commerce now handles real orders with multiple products. The architecture remains clean: entities know how to calculate their own values, controllers orchestrate the flow, and interfaces keep coupling low.
Check the full code here: php-ecommerce-architecture
This is part 2 of my software architecture series. Read part 1 here.
Top comments (0)