php
article··updated ·7 min read

State machines in PHP: an explicit order lifecycle with enums, locks and events

#php #laravel #design-patterns #state-machine #fsm #order-management

An order, a subscription and a loan application all have a lifecycle, so each can be described as a state machine. In most codebases that machine is implicit. It's just a status column plus if checks in whatever services change it. Below I show how to make it explicit in PHP 8.3 and Laravel, how to apply transitions safely under concurrency, and where side effects should go.

The implicit version

class OrderService
{
    public function markPaid(Order $order): void
    {
        if ($order->status !== 'payment_pending') {
            throw new \LogicException("Cannot mark order {$order->id} as paid");
        }
        // ...
    }

    public function cancel(Order $order): void
    {
        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {
            throw new \LogicException("Cannot cancel order {$order->id}");
        }
        // ...
    }
}

Each method checks whatever it considers forbidden. The full set of allowed transitions isn't written down anywhere. You can only reconstruct it by reading every method that touches status. So a new code path that changes status, be it a controller, an import command or an admin panel, has no rule to follow unless its author finds and copies the right check. On top of that, cancel() works from a deny list. Any status added later is cancellable by default, and the business probably doesn't want that.

Transitions in one place

A backed enum can hold both the states and the transition graph:

enum OrderStatus: string
{
    case Draft = 'draft';
    case PaymentPending = 'payment_pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
    case Refunded = 'refunded';

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Draft => [self::PaymentPending, self::Cancelled],
            self::PaymentPending => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Refunded],
            self::Shipped => [self::Delivered],
            self::Delivered => [self::Refunded],
            self::Cancelled, self::Refunded => [],
        };
    }

    public function canTransitionTo(self $to): bool
    {
        return in_array($to, $this->allowedTransitions(), true);
    }

    public function isFinal(): bool
    {
        return $this->allowedTransitions() === [];
    }
}

Now the rules are an allow list. A new case without its own match arm throws UnhandledMatchError the first time allowedTransitions() runs for it, and PHPStan (level 4 and up) or Psalm flag the non-exhaustive match before that.

On the model, cast the column to the enum (protected $casts = ['status' => OrderStatus::class];) so the rest of the code never compares raw strings.

Applying a transition

Checking isn't enough on its own. Two requests can read the same order in payment_pending, both pass canTransitionTo(Paid), both write. So the transition reads and writes the row under a lock:

final class OrderTransitions
{
    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order
    {
        return DB::transaction(function () use ($orderId, $to, $reason): Order {
            $order = Order::query()->lockForUpdate()->findOrFail($orderId);
            $from = $order->status;

            if (! $from->canTransitionTo($to)) {
                throw InvalidTransition::between($order->id, $from, $to);
            }

            $order->status = $to;
            $order->status_changed_at = now();
            $order->save();

            $order->statusHistory()->create([
                'from' => $from->value,
                'to' => $to->value,
                'reason' => $reason,
            ]);

            OrderStatusChanged::dispatch($order->id, $from, $to);

            return $order;
        });
    }
}

lockForUpdate() adds FOR UPDATE to the SELECT. A second transaction wanting the same row waits for the first one to commit, then reads the new status, and the check rejects the transition. That's how it works in MySQL and PostgreSQL at the default isolation level. SQLite ignores the lock, and PostgreSQL under REPEATABLE READ gives the second transaction a serialization error instead of the new status. The history table keeps what changed, when and why (add the user's id if you need to know who).

The history table should be append-only, and its rows need their own ids. In CookieDomain, my cookie consent system, the consent log was append-only, but the controller used the consent_id sent back by the cookie, which is the id of the previous consent, as the primary key of the new row. So every change of consent after the first one failed with 1062 Duplicate entry. The fix: each row gets a fresh UUID, and the previous consent's id goes to prev_consent_id.

If you'd rather not hold a lock, a conditional update also guarantees the transition is applied once. It bypasses apply(), though, so it writes no history and dispatches no OrderStatusChanged, and a query-level update() skips model events too:

$affected = Order::whereKey($orderId)
    ->where('status', OrderStatus::PaymentPending->value)
    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);

if ($affected === 0) {
    // no such order, or another request changed the status first
}

It works well on hot paths like payment webhooks. The locked version is easier to extend once the transition also has to read other data or write several rows.

Repeated calls from external systems

Payment providers retry webhooks, and Stripe's docs say an endpoint might occasionally receive the same event more than once. So the same "payment succeeded" event can show up twice. The second delivery finds the order already paid. There's no transition from Paid to Paid. For callers like that, "already in the target state" should count as success:

if ($from === $to) {
    return $order; // idempotent repeat, nothing to do
}

This check goes right after $from = $order->status;, before canTransitionTo(), in a separate variant of apply() for idempotent callers (webhooks, retried jobs), for example applyIdempotent(). The generic apply() stays strict: a user who clicks "Cancel" on an order that's already cancelled should get an error message. Comparing states isn't enough when the repeated event arrives after the order has already moved on to shipped. That's why Stripe suggests logging the ids of processed events and skipping those already logged.

As long as every status write goes through apply() or the conditional update, the order changes to paid once, but the provider can still charge twice: when the customer clicks "Pay" twice, two requests can each create a payment at the provider. That has to be stopped where the payment gets created. Create it only in the Draft → PaymentPending transition, under the same lock (the row stays locked for the whole HTTP call, so give that call a short timeout), and let the second request find PaymentPending and return the existing payment instead of creating another. The lock covers only your database, so the call to the provider needs an idempotency key derived from the order id (Stripe accepts it in the Idempotency-Key header): if the process dies after the provider created the payment but before the commit, a retry with the same key and the same parameters gets the same payment back. Stripe may remove the key after 24 hours, and with different parameters (a changed amount, say) it returns an error.

Side effects go to events

Sending an email, reserving stock or notifying analytics inside the transition ties the state machine to those systems. And a failed email must not roll back the payment status, because the payment already happened and a missing email doesn't change that.

So the transition only dispatches an event. That event implements ShouldDispatchAfterCommit, which means listeners don't run if the transaction rolls back and don't see the old row state when they read through another connection:

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Events\Dispatchable;

final class OrderStatusChanged implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public readonly int $orderId,
        public readonly OrderStatus $from,
        public readonly OrderStatus $to,
    ) {}
}

final class SendPaymentConfirmation implements ShouldQueue
{
    public function handle(OrderStatusChanged $event): void
    {
        if ($event->to !== OrderStatus::Paid) {
            return;
        }

        $order = Order::findOrFail($event->orderId);
        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));
    }
}

Each queued listener runs as a separate job with its own attempts (Laravel attempts a job once unless you raise the limit on the listener or with queue:work --tries). With one attempt, a job that hits a dead mail server lands in failed_jobs, and the email doesn't go out until someone runs queue:retry. If the listener has several attempts with a delay between them ($tries and $backoff), the email goes out on a later attempt if the server is back by then, and in neither case does the order status change.

Tests that check the graph

Testing every pair of states against a copy of the same table just duplicates the definition. It's more useful to test properties of the graph, plus the business rules that matter:

public function test_every_status_is_reachable_from_draft(): void
{
    $seen = [OrderStatus::Draft];
    $queue = [OrderStatus::Draft];

    while ($queue !== []) {
        foreach (array_shift($queue)->allowedTransitions() as $next) {
            if (! in_array($next, $seen, true)) {
                $seen[] = $next;
                $queue[] = $next;
            }
        }
    }

    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);
    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));
}

public function test_shipped_order_cannot_be_cancelled(): void
{
    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));
}

The reachability test fails when someone adds a status with no way into it. The Shipped/Cancelled test exists because that rule came from the business, so a change to the graph there should have to go through someone.

Limits and alternatives

Several independent dimensions, like payment, fulfillment and invoicing, are better modeled as several small machines than as one status holding every combination. Four fulfillment states (new, shipped, delivered, cancelled) times four payment states (none, pending, paid, refunded) is already 16 combinations in a single status. A boolean flag with no rules attached (is_archived, say) doesn't need a state machine at all.

Database CHECK constraints can restrict the set of values, but not transitions. You can enforce transitions in a trigger, though that moves business rules to a place most PHP teams don't review.

There are ready-made components: the Symfony Workflow component, which has a state_machine type, and spatie/laravel-model-states for Laravel. Symfony Workflow adds guards, transition events and a command that dumps the graph. The Spatie package gives you state classes, custom transition classes and transition events. The enum above is enough until transitions need their own guards or logic; that's where those libraries start to pay off.

end of node