Refactoring: Handling Order Status with State Pattern
In e-commerce applications or workflow management systems, the State of an object is often the source of complexity.
For example, an Order might have states like: Pending -> Paid -> Processing -> Shipped -> Completed (or Cancelled).
The Problem: "Fat Model" and Endless If/Else
The common (and unscalable) approach is to stuff logic into the Model or Service:
class Order extends Model
{
public function cancel()
{
if ($this->status === 'completed') {
throw new Exception("Cannot cancel a completed order");
}
if ($this->status === 'shipped') {
// Logic to return to warehouse, call shipping API...
}
if ($this->status === 'paid') {
// Logic to refund money...
}
$this->update(['status' => 'cancelled']);
}
}
Every time a new state is added, you have to go modify all methods (cancel, ship, pay...) to add if/else. This violates the Open/Closed principle of SOLID.
The Solution: State Pattern
The State Pattern allows an object to alter its behavior when its internal state changes. We will separate each state into its own distinct Class.
1. Define Interface
interface OrderStateContract
{
public function pay(): void;
public function ship(): void;
public function cancel(): void;
}
2. Base Abstract Class (Optional)
To avoid repeating code for disallowed actions (e.g., cannot ship while Pending), we use an abstract class:
abstract class OrderState implements OrderStateContract
{
protected Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
public function pay(): void
{
throw new Exception("This state cannot be paid.");
}
// ... default implementations throw exceptions
}
3. Implement Concrete States
PendingState:
class PendingState extends OrderState
{
public function pay(): void
{
// Payment logic
// ...
// Transition state
$this->order->transitionTo(new PaidState($this->order));
}
public function cancel(): void
{
$this->order->transitionTo(new CancelledState($this->order));
}
}
PaidState:
class PaidState extends OrderState
{
public function ship(): void
{
// Call Shipping API
// ...
$this->order->transitionTo(new ShippedState($this->order));
}
public function cancel(): void
{
// Refund logic specific to this state
PaymentGateway::refund($this->order);
$this->order->transitionTo(new CancelledState($this->order));
}
}
4. Integrate into Model
The Order model now acts as the Context:
class Order extends Model
{
// Assume 'state_class' column stores the current state class name
// Or map from enum status to class
public function state(): OrderStateContract
{
$stateClass = $this->state_class ?? PendingState::class;
return new $stateClass($this);
}
public function transitionTo(OrderStateContract $state)
{
$this->update(['state_class' => get_class($state)]);
// Can fire transition event logs here
}
// Delegate actions to state
public function pay() => $this->state()->pay();
public function cancel() => $this->state()->cancel();
public function ship() => $this->state()->ship();
}
Benefits
- Single Responsibility: Logic for
Paidstate resides only inPaidStateclass. - Open/Closed: Want to add
Refundedstate? Just create a new class, no need to touch old logic. - Clean Code: Completely eradicates
if ($status === '...')chains. - Testability: Easy to unit test each State individually.
Using Packages?
If you don't want to implement it yourself, laravel-model-status or spatie/laravel-model-states are excellent choices. Especially the Spatie package which supports storing status in DB and mapping to class automatically very smoothly.
Conclusion
The State Pattern is a powerful weapon when you feel your business process management is getting messy. It turns complex code into small, understandable, and much safer Lego blocks.