Originally published on phpdesignpatterns.dev — part of the PHP design patterns course. View this lesson on the original site.
One interface, one rule per class
Strategy says: when an operation has several genuinely different implementations, pull each implementation into its own object behind a shared interface, and let the caller pick which one runs.
Applied to the discount calculator:
After
<?php
interface CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int;
}
final class NoDiscount implements CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int
{
return 0;
}
}
final class StandardDiscount implements CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int
{
if ($totalInPence <= 5000) {
return 0;
}
return (int) round($totalInPence * 0.05);
}
}
final class LoyaltyDiscount implements CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int
{
return (int) round($totalInPence * 0.10);
}
}
final class LoyaltyPlusDiscount implements CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int
{
$discount = (int) round($totalInPence * 0.15);
return min($discount, 2000);
}
}
final class EmployeeDiscount implements CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int
{
return (int) round($totalInPence * 0.40);
}
}
final class PartnerDiscount implements CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int
{
return (int) round($totalInPence * 0.20) + 500;
}
}
The caller picks a strategy and asks it the question:
<?php
final class Checkout
{
public function __construct(private CalculatesDiscount $discount) {}
public function totalInPence(int $subtotalInPence): int
{
return $subtotalInPence - $this->discount->calculateDiscount($subtotalInPence);
}
}
Putting it together
In real code, the customer type comes from somewhere — a session, a user record, a request payload — and the caller picks the matching strategy:
<?php
$strategy = match ($customerType) {
'guest' => new NoDiscount(),
'standard' => new StandardDiscount(),
'loyalty' => new LoyaltyDiscount(),
'loyalty_plus' => new LoyaltyPlusDiscount(),
'employee' => new EmployeeDiscount(),
'partner' => new PartnerDiscount(),
};
$checkout = new Checkout($strategy);
$totalInPence = $checkout->totalInPence($subtotalInPence);
That match is the only place the customer-type strings live. Checkout itself never asks "what kind of customer is this?" — it just asks the strategy "what's the discount?" and subtracts it. Lesson 2.8 shows how to lift that mapping into a small factory so the strings live in exactly one file.
Why this is better
- Each rule is now one small class. A change to
loyalty_plustouchesLoyaltyPlusDiscountand nothing else. - The contract is explicit. Every strategy answers the same question: "given this total, what's the discount in pence?"
- Adding a
StudentDiscountis a new file. No existing class has to change. - Each rule is unit-testable in isolation with no setup beyond constructing it.
- The big
ifchain is gone. The branching has moved out of the calculator and into the act of choosing which strategy to use — which is usually a small map (covered in lesson 2.8).
The rest of this chapter is about when this trade is worth making, how to do it without introducing churn, and when not to bother.
From PHP design patterns — Strategy: replacing branching with swappable behaviour