Laravel Magazine
Building a Simple Order State Machine with PHP Enums and Eloquent

Building a Simple Order State Machine with PHP Enums and Eloquent

Eric Van Johnson ·

Order statuses look simple until you actually try to enforce them. A Pending order can become Paid or Cancelled, but it should never jump straight to Shipped. A Refunded order shouldn't be able to go back to Paid. Without something enforcing this graph, you end up with $order->update(['status' => 'shipped']) calls scattered across the codebase, and eventually one of them runs against an order that was never actually paid for.

You don't need a package to fix this. PHP's backed enums plus a couple of small additions to the Eloquent model get you a real state machine with almost no ceremony. Here's how to build one for a PhpArchitect order system.

Step 1: Define the OrderStatus enum

Start with a backed enum listing every valid status, plus a method that encodes which transitions are legal:

<?php

namespace App\Enums;

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

    public function canTransitionTo(OrderStatus $next): bool
    {
        return match ($this) {
            self::Pending => in_array($next, [self::Paid, self::Cancelled], true),
            self::Paid => in_array($next, [self::Shipped, self::Refunded, self::Cancelled], true),
            self::Shipped => $next === self::Refunded,
            self::Cancelled, self::Refunded => false,
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Pending Payment',
            self::Paid => 'Paid',
            self::Shipped => 'Shipped',
            self::Cancelled => 'Cancelled',
            self::Refunded => 'Refunded',
        };
    }
}

The match expression is the entire state graph in one readable block: a pending order can move to paid or cancelled, a paid order can ship, get refunded, or (rarely) get cancelled before fulfillment, a shipped order can only be refunded, and cancelled/refunded are terminal states with no way out.

Step 2: Create the custom exception

<?php

namespace App\Exceptions;

use App\Enums\OrderStatus;
use RuntimeException;

class InvalidOrderTransitionException extends RuntimeException
{
    public static function from(OrderStatus $current, OrderStatus $attempted): self
    {
        return new self(sprintf(
            'Cannot transition order from "%s" to "%s".',
            $current->value,
            $attempted->value
        ));
    }
}

Step 3: Cast the status column and add the event

Assume the orders table has a status string column. Define an OrderStatusChanged event:

<?php

namespace App\Events;

use App\Enums\OrderStatus;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderStatusChanged
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public Order $order,
        public OrderStatus $from,
        public OrderStatus $to,
    ) {}
}

Now the Order model itself. Laravel 11+ uses the casts() method instead of the $casts property, and enums are cast natively:

<?php

namespace App\Models;

use App\Enums\OrderStatus;
use App\Events\OrderStatusChanged;
use App\Exceptions\InvalidOrderTransitionException;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
            'total_cents' => 'integer',
        ];
    }

    public function transitionTo(OrderStatus $status): void
    {
        if (! $this->status->canTransitionTo($status)) {
            throw InvalidOrderTransitionException::from($this->status, $status);
        }

        $from = $this->status;

        $this->update(['status' => $status]);

        event(new OrderStatusChanged($this, $from, $status));
    }
}

transitionTo() is the only path any application code should use to change an order's status. It checks the enum's own rules, updates the column, and fires an event so the rest of the app can react without the model needing to know who's listening.

Step 4: React to the event with a listener

Register the listener in AppServiceProvider::boot() (or via an attribute if you prefer) and have it send a notification when an order ships:

<?php

namespace App\Listeners;

use App\Enums\OrderStatus;
use App\Events\OrderStatusChanged;
use App\Notifications\OrderShippedNotification;

class NotifyCustomerOfStatusChange
{
    public function handle(OrderStatusChanged $event): void
    {
        if ($event->to !== OrderStatus::Shipped) {
            return;
        }

        $event->order->customer->notify(
            new OrderShippedNotification($event->order)
        );
    }
}
use App\Events\OrderStatusChanged;
use App\Listeners\NotifyCustomerOfStatusChange;
use Illuminate\Support\Facades\Event;

Event::listen(OrderStatusChanged::class, NotifyCustomerOfStatusChange::class);

Using it in a controller looks like this:

public function markPaid(Order $order)
{
    $order->transitionTo(OrderStatus::Paid);

    return redirect()->back();
}

Step 5: Test that invalid transitions actually throw

This is the whole point of building the state machine — verify it holds up under a Pest test:

<?php

use App\Enums\OrderStatus;
use App\Exceptions\InvalidOrderTransitionException;
use App\Models\Order;

it('throws when transitioning a shipped order back to pending', function () {
    $order = Order::factory()->create([
        'status' => OrderStatus::Shipped,
    ]);

    $order->transitionTo(OrderStatus::Pending);
})->throws(InvalidOrderTransitionException::class);

it('allows a paid order to transition to shipped', function () {
    $order = Order::factory()->create([
        'status' => OrderStatus::Paid,
    ]);

    $order->transitionTo(OrderStatus::Shipped);

    expect($order->fresh()->status)->toBe(OrderStatus::Shipped);
});

Or the equivalent in PHPUnit:

public function test_it_throws_when_transitioning_a_shipped_order_back_to_pending(): void
{
    $order = Order::factory()->create(['status' => OrderStatus::Shipped]);

    $this->expectException(InvalidOrderTransitionException::class);

    $order->transitionTo(OrderStatus::Pending);
}

Wrap-up

None of this required a state machine package. A backed enum owns the transition graph, the model's transitionTo() method is the single enforced entry point for changing status, an event decouples side effects like notifications from the transition itself, and a couple of Pest tests lock the behavior in place so a future refactor can't quietly reopen an invalid path. For most apps, this is genuinely enough — reach for a full workflow library only when the graph gets complicated enough that a match expression stops being readable.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.