<?php
namespace App\Orders;
enum OrderStatus: string
{
case Cart = 'cart';
case PendingPayment = 'pending_payment';
case Paid = 'paid';
case Shipped = 'shipped';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function canTransitionTo(self $target): bool
{
return in_array($target, $this->allowedTargets(), true);
}
public function isTerminal(): bool
{
return in_array($this, [self::Completed, self::Cancelled], true);
}
private function allowedTargets(): array
{
return match ($this) {
self::Cart => [self::PendingPayment, self::Cancelled],
self::PendingPayment => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped => [self::Completed],
self::Completed, self::Cancelled => [],
};
}
}
<?php
namespace App\Orders;
use App\Events\OrderStatusChanged;
use App\Exceptions\InvalidTransitionException;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class OrderStateMachine
{
private array $guards;
public function __construct()
{
$this->guards = [
OrderStatus::Paid->value => fn (Order $o, array $ctx) => !empty($ctx['payment_intent']),
OrderStatus::Shipped->value => fn (Order $o) => $o->items()->exists(),
];
}
public function transitionTo(Order $order, OrderStatus $target, array $context = []): Order
{
$current = $order->status;
if ($current->isTerminal() || !$current->canTransitionTo($target)) {
throw new InvalidTransitionException(
"Cannot move order {$order->id} from {$current->value} to {$target->value}."
);
}
$guard = $this->guards[$target->value] ?? null;
if ($guard !== null && !$guard($order, $context)) {
throw new InvalidTransitionException(
"Guard failed for transition to {$target->value}."
);
}
return DB::transaction(function () use ($order, $current, $target) {
$order->update(['status' => $target]);
event(new OrderStatusChanged($order, $current, $target));
return $order->fresh();
});
}
}
<?php
namespace App\Http\Controllers;
use App\Exceptions\InvalidTransitionException;
use App\Models\Order;
use App\Orders\OrderStateMachine;
use App\Orders\OrderStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function __construct(private OrderStateMachine $machine)
{
}
public function pay(Request $request, Order $order): JsonResponse
{
$data = $request->validate([
'payment_intent' => ['required', 'string'],
]);
return $this->attempt(fn () => $this->machine->transitionTo(
$order,
OrderStatus::Paid,
['payment_intent' => $data['payment_intent']]
));
}
public function ship(Order $order): JsonResponse
{
return $this->attempt(fn () => $this->machine->transitionTo($order, OrderStatus::Shipped));
}
public function complete(Order $order): JsonResponse
{
return $this->attempt(fn () => $this->machine->transitionTo($order, OrderStatus::Completed));
}
private function attempt(callable $transition): JsonResponse
{
try {
return response()->json($transition());
} catch (InvalidTransitionException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
}
This snippet models a multi-step checkout as an explicit state machine so that order status changes become deliberate, validated transitions rather than scattered ->update(['status' => ...]) calls. The core idea is that an order has a finite set of statuses and only certain moves between them are legal; encoding that legality in one place prevents illegal jumps (e.g. shipping an unpaid order) and gives every transition a single hook for side effects.
The OrderStatus enum defines the states as a backed enum and centralizes the transition table in canTransitionTo(). Keeping the adjacency map next to the states means the rules are readable in one glance and impossible to contradict elsewhere. The isTerminal() helper marks Completed and Cancelled as dead ends, which the machine uses to reject any further moves.
The OrderStateMachine class is the enforcement layer. Its transitionTo() method first asks the current status whether the target is reachable, throwing an InvalidTransitionException when it is not, so bad transitions fail loudly at the boundary instead of corrupting data. Guards registered per target status via $guards run additional runtime checks — for instance Paid requires a captured payment and Shipped requires items — letting business rules that the static table cannot express veto a move. The whole thing runs inside DB::transaction() so the status write and the fired OrderStatusChanged event commit atomically.
CheckoutController shows the machine driving real HTTP steps. Each action (pay, ship, complete) delegates to transitionTo() and translates a thrown InvalidTransitionException into a 422 response, so the controller stays thin and the domain owns the rules. Note how pay() passes payment context through so the guard can inspect it.
The trade-off is a little upfront ceremony: every legal path must be declared. In return, the states are self-documenting, side effects are consolidated behind events, and adding a step like Refunded means editing one transition table plus one guard. This pattern pays off whenever an entity moves through a lifecycle with real consequences at each step — orders, subscriptions, KYC flows — and guards are the right tool when a transition depends on live data rather than just the previous state.
Related snips
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Laravel database migrations for schema management
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Laravel soft deletes for data retention
<?php
namespace App\Notifications;
use App\Models\Post;
use Illuminate\Bus\Queueable;
Laravel notifications for multi-channel messaging
Share this code
Here's the card — post it anywhere.