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 payment_pending status. A frontend timeout caused the customer to click "Pay" again. The second click triggered a new payment intent. Both intents succeeded 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 mid-flight through payment collection.
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 transition from payment_pending → paid no longer exists. The second payment attempt had nowhere valid 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 ship(Order $order): void
{
if (!in_array($order->status, ['paid', 'partially_paid'])) {
throw new \LogicException("Cannot ship 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 cancel() logic in a controller, forgets to check the status, and an order gets cancelled after it has already been shipped. The valid transitions are nowhere explicit. They exist only as the sum of all the if checks across all 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 canTransition(Order $order, OrderStatus $to): bool
{
return in_array($to, self::TRANSITIONS[$order->status->value] ?? [], strict: true);
}
public function transition(Order $order, OrderStatus $to): void
{
if (!$this->canTransition($order, $to)) {
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 entire specification of your workflow. To add a new transition, you add one entry. To understand which transitions are possible from any state, you read one array. To prove that cancelled → refunded is impossible, you look at the empty array.
Side effects belong in listeners
The classic mistake after adopting explicit state machines is putting side effects in the transition method:
// Do not do this
public function transition(Order $order, OrderStatus $to): void
{
// validate...
$order->status = $to;
if ($to === OrderStatus::Paid) {
$this->emailService->sendPaymentConfirmation($order);
$this->inventoryService->reserveItems($order);
$this->analyticsService->trackConversion($order);
}
// ...
}
The state machine is now coupled to email, inventory, and analytics. Testing the transition requires mocking three dependencies. More importantly: if email sending fails, does the order remain unpaid? If analytics throws, does the customer not get their items?
Dispatch an event instead. Let listeners decide what to do with it:
// 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 a concurrent system — two requests can simultaneously attempt to transition the same order. Database-level protection:
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();
// Event is dispatched inside the transaction — if save() fails,
// the event is not dispatched (assuming DB-backed event queue)
});
}
lockForUpdate() prevents a second concurrent request from reading the payment_pending order until the first transaction commits. The second request then reads the paid order, finds no valid transition, and throws InvalidTransitionException. No double charge.
What the audit trail looks like
Every OrderStatusTransitioned event persisted to an order_status_history table gives you a complete audit trail with almost no extra effort:
SELECT status_from, status_to, created_at, triggered_by
FROM order_status_history
WHERE order_id = 4821
ORDER BY created_at;
-- status_from | status_to | created_at | triggered_by
-- draft | payment_pending | 2024-02-10 14:23:01 | user:8823
-- payment_pending | paid | 2024-02-10 14:23:04 | stripe-webhook:pi_abc123
-- paid | shipped | 2024-02-10 14:55:17 | fulfillment-worker
-- shipped | delivered | 2024-02-11 09:14:33 | delivery-webhook
When a customer calls support saying "I paid but nothing happened," you read this table. The answer is one query away.
What I watch for in code review
A status column with no corresponding state machine definition is a liability waiting to be exploited. My question: can the application reach an invalid state combination?
An order with status = 'shipped' and no shipping address — that should be impossible. An order with status = 'refunded' and payment_status = 'pending' — also impossible, if the machine is defined correctly.
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. The TRANSITIONS constant is the documentation, the validation, and the test specification all at once.