Refactoring: Handling Order Status with State Pattern

· 3 min read

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

  1. Single Responsibility: Logic for Paid state resides only in PaidState class.
  2. Open/Closed: Want to add Refunded state? Just create a new class, no need to touch old logic.
  3. Clean Code: Completely eradicates if ($status === '...') chains.
  4. 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.

Comments