State is a behavioural pattern that allows an object to change its behaviour when its internal state changes. It looks like the object changed its class. The classic example is an order state machine – an order in “pending” status behaves differently from one in “processing”. I show an implementation in PHP and compare it with Strategy, which it superficially resembles.
Without the pattern – the growing switch
<?php
// Without State pattern - one class handling all states
class Order
{
private string $state = 'pending';
public function pay(): void
{
match($this->state) {
'pending' => $this->state = 'processing',
'processing' => throw new \RuntimeException('Already paid'),
'complete' => throw new \RuntimeException('Already complete'),
'cancelled' => throw new \RuntimeException('Order cancelled'),
};
}
public function ship(): void
{
match($this->state) {
'processing' => $this->state = 'shipped',
'pending' => throw new \RuntimeException('Pay first'),
// ... more cases
};
}
// Adding a new state requires modifying every method - OCP violation
}
State pattern – each state is a class
<?php
declare(strict_types=1);
// State interface - all states implement the same operations
interface OrderStateInterface
{
public function pay(Order $order): void;
public function ship(Order $order): void;
public function cancel(Order $order): void;
public function getName(): string;
}
// Concrete states
class PendingState implements OrderStateInterface
{
public function pay(Order $order): void
{
echo "Payment received. Moving to processing.\n";
$order->setState(new ProcessingState());
}
public function ship(Order $order): void
{
throw new \RuntimeException('Cannot ship: payment required first.');
}
public function cancel(Order $order): void
{
echo "Order cancelled.\n";
$order->setState(new CancelledState());
}
public function getName(): string { return 'pending'; }
}
class ProcessingState implements OrderStateInterface
{
public function pay(Order $order): void
{
throw new \RuntimeException('Already paid.');
}
public function ship(Order $order): void
{
echo "Order shipped.\n";
$order->setState(new ShippedState());
}
public function cancel(Order $order): void
{
echo "Order cancelled (refund will be processed).\n";
$order->setState(new CancelledState());
}
public function getName(): string { return 'processing'; }
}
class ShippedState implements OrderStateInterface
{
public function pay(Order $order): void
{
throw new \RuntimeException('Already paid and shipped.');
}
public function ship(Order $order): void
{
throw new \RuntimeException('Already shipped.');
}
public function cancel(Order $order): void
{
throw new \RuntimeException('Cannot cancel a shipped order. Request return instead.');
}
public function getName(): string { return 'shipped'; }
}
class CancelledState implements OrderStateInterface
{
public function pay(Order $order): void { throw new \RuntimeException('Order cancelled.'); }
public function ship(Order $order): void { throw new \RuntimeException('Order cancelled.'); }
public function cancel(Order $order): void { throw new \RuntimeException('Already cancelled.'); }
public function getName(): string { return 'cancelled'; }
}
// Context - delegates operations to its current state
class Order
{
private OrderStateInterface $state;
private array $history = [];
public function __construct(private int $id)
{
$this->state = new PendingState(); // initial state
}
public function setState(OrderStateInterface $state): void
{
$this->history[] = $this->state->getName();
$this->state = $state;
}
// Delegate to state - Order does not know what state does
public function pay(): void { $this->state->pay($this); }
public function ship(): void { $this->state->ship($this); }
public function cancel(): void { $this->state->cancel($this); }
public function getStatus(): string { return $this->state->getName(); }
public function getHistory(): array { return $this->history; }
}
// Usage
$order = new Order(42);
echo $order->getStatus(); // pending
$order->pay();
echo $order->getStatus(); // processing
$order->ship();
echo $order->getStatus(); // shipped
// $order->cancel(); // RuntimeException: Cannot cancel a shipped order
State serialisation – persist to database
<?php
// Persist and restore the state from a string identifier
class OrderStateFactory
{
private array $states = [
'pending' => PendingState::class,
'processing' => ProcessingState::class,
'shipped' => ShippedState::class,
'cancelled' => CancelledState::class,
];
public function create(string $name): OrderStateInterface
{
$class = $this->states[$name]
?? throw new \InvalidArgumentException("Unknown state: {$name}");
return new $class();
}
}
// In repository - restore order with correct state from DB
class OrderRepository
{
public function getById(int $id): Order
{
$row = $this->db->fetchOne('SELECT * FROM orders WHERE id = ?', [$id]);
$order = new Order($id);
$order->setState($this->stateFactory->create($row['status']));
return $order;
}
public function save(Order $order): void
{
$this->db->update('orders', ['status' => $order->getStatus()], ['id' => $order->getId()]);
}
}
State vs Strategy – the key distinction
| Aspect | State | Strategy |
|---|---|---|
| Who changes the behaviour object? | The state itself (or the context) | The client / external code |
| States aware of each other? | Yes – state transitions to other states | No – strategies are independent |
| Lifecycle | Context moves through states over time | Algorithm selected once or occasionally |
| Example | Order lifecycle: pending → processing → shipped | Shipping cost: flat vs weight-based |
Summary
State eliminates growing switch statements in objects that change behaviour over their lifecycle. Each state encapsulates what is allowed and what is not in that state, and handles its own transitions. In Magento 2 the order state machine is the canonical real-world example. The pattern makes invalid state transitions impossible by design rather than by guard clauses scattered across methods.
