State Pattern vs. Enums in Modern PHP

In many PHP and Laravel applications, entity lifecycles start simple. An Order can be:

  • Pending
  • Paid
  • Shipped
  • Cancelled

When PHP introduced native Enums, they became the perfect fit for this kind of state modeling. They are type-safe, database-friendly, and much cleaner than arbitrary strings spread across the codebase.

For simple workflows, Enums are often exactly the right solution. The problem begins when states stop being just labels and start accumulating behavior.

This article explores where Enums work well, where they start breaking down, and how the State Pattern can help without introducing unnecessary complexity or framework-heavy abstractions.

Previous articlet in Refactoring cattegory: https://codecraftdiary.com/2026/05/02/mastering-value-objects-in-php/

Enums Are Excellent — Until They Aren’t

For simple workflows, Enums are clean and maintainable.

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}
PHP

This is ideal when states are primarily used for:

  • Filtering and querying data
  • Display logic and badges in UI
  • Basic validation
  • API serialization
  • Database persistence

Problems start appearing when business rules become state-dependent. A common first step is adding helper methods directly into the Enum:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function canBeCancelled(): bool
    {
        return match($this) {
            self::Pending, self::Paid => true,
            self::Shipped, self::Cancelled => false,
        };
    }
}
PHP

This is still perfectly reasonable and respects the KISS principle.

But over time, workflows tend to evolve. A cancellation process may eventually require refunding payments, restocking inventory, notifying external systems, creating audit logs, or dispatching events.

At that point, the Enum slowly stops being a simple value object and starts becoming a workflow engine.

The Hidden Problem: Growing Coupling

Consider how a bloating Enum typically looks in production:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function cancel(
        Order $order,
        PaymentGateway $gateway,
        InventoryManager $inventory
    ): void {
        match ($this) {
            self::Pending => $order->updateStatus(self::Cancelled),

            self::Paid => $this->executeCancellationWithRefund($order, $gateway, $inventory),

            self::Shipped => throw new LogicException('Cannot cancel a shipped order.'),
            self::Cancelled => throw new LogicException('Order is already cancelled.'),
        };
    }

    private function executeCancellationWithRefund(Order $order, PaymentGateway $gateway, InventoryManager $inventory): void
    {
        $gateway->refund($order->payment_id);
        $inventory->restock($order->items);
        $order->updateStatus(self::Cancelled);
    }
}
PHP

The issue here is not the number of lines. The issue is coupling.

The Enum now knows about payment infrastructure, inventory management, business transitions, and side effects. Adding a new state such as PartiallyRefunded now requires modifying a growing conditional structure that centralizes unrelated responsibilities.

This is where applications experience state explosion—the point where transitions and side effects become increasingly difficult to isolate and reason about. At this stage, the code may still look “short,” but it is no longer simple.

The State Pattern: Isolating Behavior

The State Pattern addresses this by moving behavior into dedicated state objects. Instead of one large conditional structure, each state becomes responsible for its own transitions and rules.

1. Define the Workflow Contract

interface OrderState
{
    public function cancel(Order $order): void;
    public function ship(Order $order): void;
    public function toValue(): string;
}
PHP

The interface should stay minimal. Only include operations whose behavior actually changes depending on the state.

2. Create Small, Focused State Classes

Each state becomes an isolated, testable component. Look at how clean the responsibilities become:

Pending State

readonly class PendingState implements OrderState
{
    public function cancel(Order $order): void
    {
        $order->transitionTo(new CancelledState());
    }

    public function ship(Order $order): void
    {
        throw new LogicException('Cannot ship an unpaid order.');
    }

    public function toValue(): string
    {
        return 'pending';
    }
}
PHP

Paid State

readonly class PaidState implements OrderState
{
    public function __construct(
        private PaymentGateway $gateway,
        private InventoryManager $inventory,
    ) {}

    public function cancel(Order $order): void
    {
        $this->gateway->refund($order->payment_id);
        $this->inventory->restock($order->items);
        
        $order->transitionTo(new CancelledState());
    }

    public function ship(Order $order): void
    {
        $order->transitionTo(new ShippedState());
    }

    public function toValue(): string
    {
        return 'paid';
    }
}
PHP

Shipped State

readonly class ShippedState implements OrderState
{
    public function cancel(Order $order): void
    {
        throw new LogicException('The order has already been shipped.');
    }

    public function ship(Order $order): void
    {
        throw new LogicException('Order is already shipped.');
    }

    public function toValue(): string
    {
        return 'shipped';
    }
}
PHP

Most importantly, adding a new state no longer requires modifying a massive conditional block. This aligns naturally with the Open/Closed Principle.

3. A Pragmatic Hybrid Approach

Completely replacing Enums with raw state objects is often unnecessary in database-driven applications. In practice, the most maintainable approach is a hybrid architecture:

  • Enums handle persistence, transport, and API serialization.
  • State objects handle business behavior and side effects.

The Enum remains the canonical storage format:

enum OrderStatusEnum: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}
PHP

Resolving State Behavior Cleanly

Instead of resolving dependencies directly inside the model, a dedicated factory keeps infrastructure concerns isolated from your domain.

readonly class OrderStateFactory
{
    public function __construct(
        private PaymentGateway $gateway,
        private InventoryManager $inventory,
    ) {}

    public function make(OrderStatusEnum $status): OrderState
    {
        return match ($status) {
            OrderStatusEnum::Pending => new PendingState(),
            OrderStatusEnum::Paid => new PaidState($this->gateway, $this->inventory),
            OrderStatusEnum::Shipped => new ShippedState(),
            OrderStatusEnum::Cancelled => new CancelledState(),
        };
    }
}
PHP

Keeping the Model Lightweight

Now, the Order model stays clean and decoupled from infrastructure services. It simply orchestrates the workflow via the factory:

class Order
{
    public OrderStatusEnum $status;
    public int $payment_id;
    public array $items = [];

    public function __construct(
        private readonly OrderStateFactory $stateFactory,
    ) {}

    public function transitionTo(OrderState $state): void
    {
        $this->status = OrderStatusEnum::from($state->toValue());
    }

    public function cancel(): void
    {
        $this->stateFactory
            ->make($this->status)
            ->cancel($this);
    }

    public function ship(): void
    {
        $this->stateFactory
            ->make($this->status)
            ->ship($this);
    }
}
PHP

This keeps your persistence simple, business logic isolated, dependencies explicit, and workflows extensible—all without introducing heavy framework abstractions.

Testing Becomes Significantly Easier

One of the biggest advantages of state objects is test isolation. Instead of testing a large Enum with multiple branches and heavy mocking setups, each workflow state can be verified independently:

  • PendingStateTest — Verify that cancellation transitions directly to cancelled.
  • PaidStateTest — Assert that the payment gateway receives the refund call and inventory is restocked.
  • ShippedStateTest — Assert that exceptions are thrown correctly on forbidden actions.

This dramatically reduces test setup complexity and makes transition rules much easier to verify.

When Should You Use Enums vs. State Objects?

Use Enums when:

  • The state is primarily a static label.
  • Transitions are simple and linear.
  • Behavior differences between states are minimal.
  • No external services or infrastructure are involved.
  • Examples: Blog post status (Draft, Published), user visibility flags, or filtering categories.

Use the State Pattern when:

  • Transitions trigger side effects or external APIs.
  • States require completely different validation rules.
  • Workflows keep expanding with new edge cases.
  • Conditional logic (match or if/else) around the same state starts repeating across the codebase.
  • Examples: Payment workflows, fulfillment systems, subscription lifecycles, or approval pipelines.

Final Thoughts

Enums are not the enemy. In fact, they are often the best solution for simple state representation.

The real problem starts when business workflows evolve and a single Enum begins accumulating infrastructure dependencies, transition orchestration, side effects, and validation logic. At that point, the issue is no longer code length—it is responsibility density.

The State Pattern is valuable not because it is “more advanced,” but because it isolates change. And in long-lived systems, isolated change is usually what keeps complexity manageable.

Leave a Reply

Your email address will not be published. Required fields are marked *