Finite state machines in PHP: modelling order lifecycle without the spaghetti
The bug report said: "Customer was charged twice for the same order." The order was in status payment_pending. A frontend timeout caused the customer to click "Pay" again. The second click triggered a new payment intent. Both intents completed within 200 milliseconds of each other. Neither the frontend nor the backend had a mechanism to prevent a second payment on an order that was already being charged.
The fix was not a mutex. It was a state machine. The transition from payment_pending to paid can only happen once, and once it has happened, the payment_pending → paid transition simply does not exist anymore. The second payment attempt had nowhere to go.
The implicit state machine you already have
Every application with workflow concepts (orders, subscriptions, support tickets, loan applications) already has a state machine. It is just implicit: a status column in the database and if statements scattered across services that check it.
// The implicit version — found in most codebases
class OrderService
{
public function processPayment(Order $order, PaymentResult $result): void
{
if ($order->status !== 'payment_pending') {
throw new \LogicException("Cannot process payment for order in status: {$order->status}");
}
// ...
}
public function cancel(Order $order): void
{
if (in_array($order->status, ['shipped', 'delivered', 'refunded'])) {
throw new \LogicException("Cannot cancel order in status: {$order->status}");
}
// ...
}
}
This works until a new developer adds cancellation logic in a controller, forgets the status check, and an order gets cancelled after it was already shipped. The allowed transitions are nowhere explicit. They exist only as the sum of all the if checks in all the methods.
Making the machine explicit
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';
}
final class OrderStateMachine
{
// The complete allowed transition graph — one place, one truth
private const TRANSITIONS = [
OrderStatus::Draft->value => [
OrderStatus::PaymentPending,
OrderStatus::Cancelled,
],
OrderStatus::PaymentPending->value => [
OrderStatus::Paid,
OrderStatus::Cancelled,
],
OrderStatus::Paid->value => [
OrderStatus::Shipped,
OrderStatus::Refunded,
],
OrderStatus::Shipped->value => [OrderStatus::Delivered],
OrderStatus::Delivered->value => [OrderStatus::Refunded],
OrderStatus::Cancelled->value => [], // terminal
OrderStatus::Refunded->value => [], // terminal
];
public function transition(Order $order, OrderStatus $to): void
{
if (!in_array($to, self::TRANSITIONS[$order->status->value] ?? [], strict: true)) {
throw new InvalidTransitionException(
from: $order->status,
to: $to,
orderId: $order->id,
);
}
$previousStatus = $order->status;
$order->status = $to;
$order->status_changed_at = now();
// Dispatch transition event — side effects happen in listeners, not here
event(new OrderStatusTransitioned(order: $order, from: $previousStatus, to: $to));
}
}
TRANSITIONS is the complete specification of your workflow. To add a new transition, you add one entry. To understand which transitions are possible from a given state, you read one array. To prove that cancelled → refunded is impossible, you look at an empty array.
Side effects belong in listeners
The classic mistake after adopting explicit state machines is putting side effects into the transition method. The state machine is now coupled to email, inventory, and analytics, testing a transition requires mocking three dependencies. More importantly: if sending the email fails, does the order remain unpaid?
Emit an event instead and let listeners decide:
// In OrderEventSubscriber
public function onOrderStatusTransitioned(OrderStatusTransitioned $event): void
{
if ($event->to !== OrderStatus::Paid) {
return;
}
// Each listener is independently retryable, independently testable
$this->emailQueue->dispatch(new SendPaymentConfirmationEmail($event->order->id));
$this->inventoryQueue->dispatch(new ReserveOrderItems($event->order->id));
}
A failed email job does not roll back the payment status. The order is paid. The email will retry. These are separate concerns.
Persisting state safely
In a concurrent system (and every web application is one) two requests may simultaneously attempt to transition the same order. Protection at the database level:
public function transitionWithLock(int $orderId, OrderStatus $to): void
{
DB::transaction(function () use ($orderId, $to) {
// FOR UPDATE locks the row for the duration of this transaction
$order = Order::where('id', $orderId)->lockForUpdate()->firstOrFail();
$this->stateMachine->transition($order, $to);
$order->save();
});
}
lockForUpdate() prevents a second concurrent request from reading the payment_pending order until the first transaction commits. The second request will then read a paid order, find no allowed transition, and throw InvalidTransitionException. Zero double charges.
What I watch for in code review
A status column without a corresponding state machine definition is a liability waiting to be exploited. My question: can the application reach an invalid combination of states? An order with status = 'shipped' and no shipping address should be impossible. An order with status = 'refunded' and payment_status = 'pending' is also impossible, if the machine is correctly defined. If the answer to "can this reach an invalid state" is "theoretically, if two things happen in the right order", you have an implicit machine. Make it explicit.