objects namespaces and application architecture
Common Design Patterns
Design patterns are named solutions to common software design problems. In PHP jobs, they are useful because they give developers shared language for code structure, not because every problem needs a formal pattern.
You do not need to memorise a catalogue of patterns before you can build useful applications. You do need to recognise the patterns that appear often in real PHP projects: strategy, factory, adapter, decorator, repository, and simple value objects. These patterns help with the same practical problems again and again: choosing behaviour at runtime, hiding messy construction, wrapping third-party code, adding behaviour without rewriting a class, and keeping database details out of business logic.
Good pattern use should make code easier to read and change. If a pattern adds names, files, and indirection without reducing a real problem, it is probably making the code worse.
Pattern Names Are Communication
Pattern names let developers discuss structure quickly.
If someone says "this is a strategy", they usually mean several classes share the same interface and the application chooses one of them. If someone says "this is an adapter", they usually mean a class is translating one API into another API the application prefers.
The name is not the goal. The goal is a clearer design.
<?php
declare(strict_types=1);
final class DiscountCalculator
{
public function calculate(string $type, int $subtotalPence): int
{
if ($type === 'student') {
return (int) round($subtotalPence * 0.15);
}
if ($type === 'vip') {
return (int) round($subtotalPence * 0.25);
}
return 0;
}
}
This is fine while there are only two discounts. If discount rules grow, require dependencies, or need separate tests, a strategy pattern may be clearer.
Strategy Pattern
The strategy pattern lets different classes implement the same behaviour in different ways.
It is useful when you see a growing if or match statement that chooses between several algorithms. Examples include shipping methods, tax rules, payment fees, password policies, export formats, and notification channels.
<?php
declare(strict_types=1);
interface DiscountStrategy
{
public function discountFor(int $subtotalPence): int;
}
final class StudentDiscount implements DiscountStrategy
{
public function discountFor(int $subtotalPence): int
{
return (int) round($subtotalPence * 0.15);
}
}
final class VipDiscount implements DiscountStrategy
{
public function discountFor(int $subtotalPence): int
{
return (int) round($subtotalPence * 0.25);
}
}
final class Checkout
{
public function __construct(
private DiscountStrategy $discount,
) {
}
public function totalAfterDiscount(int $subtotalPence): int
{
return $subtotalPence - $this->discount->discountFor($subtotalPence);
}
}
$checkout = new Checkout(new StudentDiscount());
echo $checkout->totalAfterDiscount(10000) . PHP_EOL;
// Prints:
// 8500
The checkout does not know which discount it received. It only knows the DiscountStrategy contract.
Factory Pattern
A factory creates objects when construction has rules, choices, or setup that should not be scattered around the codebase.
Factories are common around HTTP clients, mailers, payment gateways, export writers, and objects that need validation during construction.
<?php
declare(strict_types=1);
interface DiscountStrategy
{
public function discountFor(int $subtotalPence): int;
}
final class NoDiscount implements DiscountStrategy
{
public function discountFor(int $subtotalPence): int
{
return 0;
}
}
final class StudentDiscount implements DiscountStrategy
{
public function discountFor(int $subtotalPence): int
{
return (int) round($subtotalPence * 0.15);
}
}
final class DiscountFactory
{
public function fromCode(string $code): DiscountStrategy
{
return match (strtolower(trim($code))) {
'student' => new StudentDiscount(),
default => new NoDiscount(),
};
}
}
$factory = new DiscountFactory();
$discount = $factory->fromCode('student');
echo $discount->discountFor(2000) . PHP_EOL;
// Prints:
// 300
A factory is especially helpful when the caller should not know every concrete class. Instead of spreading new StudentDiscount() and new NoDiscount() across controllers, the choice lives in one place.
Adapter Pattern
An adapter lets your application use a clean interface even when a third-party library has a different shape.
This matters in PHP because applications often depend on payment providers, email APIs, cloud storage SDKs, queue clients, and framework services. You do not want those details leaking into every class.
<?php
declare(strict_types=1);
interface ReceiptSender
{
public function send(string $email, string $message): void;
}
final class ThirdPartyMailer
{
public function deliver(array $payload): void
{
echo $payload['to'] . ': ' . $payload['body'] . PHP_EOL;
}
}
final class ThirdPartyMailerAdapter implements ReceiptSender
{
public function __construct(
private ThirdPartyMailer $mailer,
) {
}
public function send(string $email, string $message): void
{
$this->mailer->deliver([
'to' => $email,
'body' => $message,
]);
}
}
$sender = new ThirdPartyMailerAdapter(new ThirdPartyMailer());
$sender->send('orders@example.com', 'Your receipt is ready.');
// Prints:
// orders@example.com: Your receipt is ready.
The rest of the application depends on ReceiptSender, not on the third-party mailer's array payload format.
Decorator Pattern
A decorator wraps another object with the same interface to add behaviour before or after the original call.
Decorators are often used for logging, caching, metrics, retries, permission checks, and feature flags.
<?php
declare(strict_types=1);
interface ExchangeRateProvider
{
public function rateFor(string $currency): float;
}
final class FixedExchangeRateProvider implements ExchangeRateProvider
{
public function rateFor(string $currency): float
{
return match ($currency) {
'EUR' => 1.17,
'USD' => 1.25,
default => throw new InvalidArgumentException('Unsupported currency.'),
};
}
}
final class LoggingExchangeRateProvider implements ExchangeRateProvider
{
public function __construct(
private ExchangeRateProvider $inner,
) {
}
public function rateFor(string $currency): float
{
echo 'Looking up ' . $currency . PHP_EOL;
return $this->inner->rateFor($currency);
}
}
$rates = new LoggingExchangeRateProvider(new FixedExchangeRateProvider());
echo $rates->rateFor('EUR') . PHP_EOL;
// Prints:
// Looking up EUR
// 1.17
The logging decorator does not replace the real provider. It adds behaviour around it.
Repository Pattern
A repository hides storage details behind collection-like methods. It is usually used for loading and saving domain objects.
Repositories are common in PHP applications that use databases. They can be helpful when business logic should not contain SQL or ORM query details.
<?php
declare(strict_types=1);
final readonly class Product
{
public function __construct(
public int $id,
public string $name,
public int $pricePence,
) {
}
}
interface ProductRepository
{
public function findById(int $id): ?Product;
}
final class InMemoryProductRepository implements ProductRepository
{
/** @var array<int, Product> */
private array $products;
public function __construct(Product ...$products)
{
$this->products = [];
foreach ($products as $product) {
$this->products[$product->id] = $product;
}
}
public function findById(int $id): ?Product
{
return $this->products[$id] ?? null;
}
}
$products = new InMemoryProductRepository(
new Product(10, 'Keyboard', 4999),
);
$product = $products->findById(10);
echo $product?->name . PHP_EOL;
// Prints:
// Keyboard
In production, another implementation might use PDO or an ORM. Code that depends on ProductRepository does not need to care.
Value Object Pattern
A value object represents a small meaningful value and protects its rules. You have already seen this idea in classes such as EmailAddress, Money, and DateRange.
<?php
declare(strict_types=1);
final readonly class Money
{
public function __construct(
public int $amountPence,
public string $currency,
) {
if ($amountPence < 0) {
throw new InvalidArgumentException('Amount cannot be negative.');
}
if (!in_array($currency, ['GBP', 'EUR', 'USD'], true)) {
throw new InvalidArgumentException('Unsupported currency.');
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currencies must match.');
}
return new self($this->amountPence + $other->amountPence, $this->currency);
}
}
$total = new Money(1200, 'GBP')->add(new Money(800, 'GBP'));
echo $total->amountPence . ' ' . $total->currency . PHP_EOL;
// Prints:
// 2000 GBP
The value object stops invalid money values travelling through the application as loose integers and strings.
When Not To Use A Pattern
Patterns are tools, not achievements. Do not add a strategy interface for one class with no sign that another implementation is needed. Do not create a factory when new Product($id, $name, $price) is already clear. Do not hide a simple query behind three layers if the surrounding codebase does not work that way.
A pattern is usually worth considering when:
- a conditional keeps growing every time a new case is added
- construction is duplicated or easy to get wrong
- third-party code is leaking into business classes
- you need to add logging, caching, retries, or metrics around an existing object
- SQL or API details are mixed into domain logic
- tests are hard because everything is tightly coupled
The professional judgement is knowing when the extra structure pays for itself.
What You Should Be Able To Do
After this lesson, you should be able to recognise common PHP design patterns in existing code and explain the problem each one solves. You should also be able to avoid pattern overuse by asking whether the structure makes the next change easier.
For junior work, the strongest signal is not naming every pattern from memory. It is being able to look at a controller, service, repository, or adapter and say what responsibility it owns, what dependency it hides, and how you would test it.
Practice
Practice: Use Strategy And Factory For Shipping Prices
Create a small checkout example where different shipping methods calculate prices differently.
Task
Build:
- a
ShippingMethodinterface - at least two shipping method implementations
- a factory that chooses the correct shipping method from a string code
- a checkout class that uses the
ShippingMethodinterface
The checkout should:
- reject negative basket totals
- calculate a total including shipping
- support a normal shipping option and an express shipping option
- fall back to a safe default when the code is unknown
Use strict types. Keep the output in the same PHP code block as comments or printed lines so the example can be run directly.
Check Your Work
Run cases for:
- standard shipping
- express shipping
- an unknown shipping code
- a negative basket total
Afterward, explain which part is the strategy and which part is the factory.
Show solution
This solution uses a strategy for each shipping calculation and a factory to choose the strategy from request or form input.
<?php
declare(strict_types=1);
interface ShippingMethod
{
public function priceFor(int $basketTotalPence): int;
}
final class StandardShipping implements ShippingMethod
{
public function priceFor(int $basketTotalPence): int
{
if ($basketTotalPence >= 5000) {
return 0;
}
return 399;
}
}
final class ExpressShipping implements ShippingMethod
{
public function priceFor(int $basketTotalPence): int
{
return 799;
}
}
final class ShippingMethodFactory
{
public function fromCode(string $code): ShippingMethod
{
return match (strtolower(trim($code))) {
'express' => new ExpressShipping(),
default => new StandardShipping(),
};
}
}
final class Checkout
{
public function __construct(
private ShippingMethod $shipping,
) {
}
public function totalFor(int $basketTotalPence): int
{
if ($basketTotalPence < 0) {
throw new InvalidArgumentException('Basket total cannot be negative.');
}
return $basketTotalPence + $this->shipping->priceFor($basketTotalPence);
}
}
$factory = new ShippingMethodFactory();
$standardCheckout = new Checkout($factory->fromCode('standard'));
$expressCheckout = new Checkout($factory->fromCode('express'));
$defaultCheckout = new Checkout($factory->fromCode('unknown'));
echo $standardCheckout->totalFor(2500) . PHP_EOL;
echo $expressCheckout->totalFor(2500) . PHP_EOL;
echo $defaultCheckout->totalFor(6000) . PHP_EOL;
try {
echo $standardCheckout->totalFor(-1) . PHP_EOL;
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// 2899
// 3299
// 6000
// Basket total cannot be negative.
StandardShipping and ExpressShipping are strategies because they share the same interface and provide different algorithms. ShippingMethodFactory is the factory because it centralises the decision about which strategy to create from a string code.
The checkout class depends only on ShippingMethod, so adding a new shipping method does not require changing the checkout calculation.