-
-
Notifications
You must be signed in to change notification settings - Fork 0
Order Workflow Example
This comprehensive example demonstrates a complete e-commerce order processing workflow using Laravel Statecraft. It covers the entire order lifecycle from creation to delivery, including payment processing, inventory management, shipping, and customer notifications.
The Order Workflow manages the complete lifecycle of an e-commerce order:
- Order Creation - Customer creates an order (draft β pending)
- Payment Processing - Payment validation and processing (pending β paid)
- Order Fulfillment - Inventory reservation and processing (paid β processing)
- Shipping - Package preparation and shipment (processing β shipped)
- Delivery - Final delivery confirmation (shipped β delivered)
- Cancellation - Handle order cancellations at any stage
# resources/state-machines/OrderStateMachine.yaml
name: OrderStateMachine
model: App\Models\Order
initial_state: pending
field: status
description: "Complete e-commerce order processing workflow"
states:
- name: pending
description: Order is awaiting payment
metadata:
color: yellow
icon: clock
editable: true
- name: paid
description: Payment has been processed successfully
metadata:
color: green
icon: check-circle
- name: processing
description: Order is being prepared for shipment
metadata:
color: blue
icon: package
estimated_duration: 24
- name: shipped
description: Order has been shipped to customer
metadata:
color: purple
icon: truck
- name: delivered
description: Order has been successfully delivered
metadata:
color: green
icon: check
final: true
- name: cancelled
description: Order was cancelled
metadata:
color: red
icon: x-circle
final: true
- name: refunded
description: Order payment has been refunded
metadata:
color: orange
icon: rotate-ccw
final: true
transitions:
- name: pay
from: pending
to: paid
guard:
and:
- HasValidPayment
- HasValidItems
- not: IsBlacklisted
action:
- ProcessPayment
- SendConfirmationEmail
- ReserveInventory
metadata:
requires_payment: true
description: "Process customer payment"
- name: process
from: paid
to: processing
guard:
and:
- HasInventory
- IsWithinBusinessHours
action:
- AllocateInventory
- NotifyWarehouse
- UpdateEstimatedDelivery
metadata:
department: warehouse
- name: ship
from: processing
to: shipped
guard:
and:
- IsPackaged
- HasShippingLabel
- HasValidAddress
action:
- CreateShipment
- GenerateTrackingNumber
- SendShippingNotification
metadata:
requires_tracking: true
- name: deliver
from: shipped
to: delivered
action:
- MarkAsDelivered
- SendDeliveryConfirmation
- RequestFeedback
- UpdateCustomerMetrics
metadata:
triggers_review_request: true
- name: cancel
from: [pending, paid, processing]
to: cancelled
guard:
or:
- IsCustomer
- IsManager
- IsWithinCancellationPeriod
action:
- CancelOrder
- ReleaseInventory
- ProcessRefund
- SendCancellationEmail
metadata:
refund_eligible: true
- name: refund
from: [delivered, cancelled]
to: refunded
guard:
and:
- IsWithinRefundPeriod
- IsRefundEligible
- or:
- IsCustomer
- IsManager
action:
- ProcessRefund
- SendRefundConfirmation
- UpdateInventory
metadata:
requires_approval: true
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Grazulex\LaravelStatecraft\Traits\HasStateMachine;
class Order extends Model
{
use HasFactory, HasStateMachine;
protected $stateMachine = 'OrderStateMachine';
protected $fillable = [
'customer_id',
'amount',
'currency',
'payment_method',
'payment_token',
'shipping_address',
'billing_address',
'notes',
'status',
'paid_at',
'shipped_at',
'delivered_at',
'tracking_number',
];
protected $casts = [
'amount' => 'decimal:2',
'shipping_address' => 'array',
'billing_address' => 'array',
'paid_at' => 'datetime',
'shipped_at' => 'datetime',
'delivered_at' => 'datetime',
];
// Relationships
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function items()
{
return $this->hasMany(OrderItem::class);
}
public function payments()
{
return $this->hasMany(Payment::class);
}
public function shipments()
{
return $this->hasMany(Shipment::class);
}
// Helper methods for guards
public function hasValidItems(): bool
{
return $this->items()->count() > 0 &&
$this->items()->sum('quantity') > 0;
}
public function hasInventory(): bool
{
foreach ($this->items as $item) {
if ($item->product->available_stock < $item->quantity) {
return false;
}
}
return true;
}
public function isPackaged(): bool
{
return !is_null($this->tracking_number);
}
public function hasValidAddress(): bool
{
return !empty($this->shipping_address['street']) &&
!empty($this->shipping_address['city']) &&
!empty($this->shipping_address['postal_code']);
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained();
$table->decimal('amount', 10, 2);
$table->string('currency', 3)->default('USD');
$table->string('payment_method')->nullable();
$table->string('payment_token')->nullable();
$table->json('shipping_address');
$table->json('billing_address');
$table->text('notes')->nullable();
$table->string('status')->default('pending');
$table->string('tracking_number')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamp('shipped_at')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
$table->index('customer_id');
});
}
public function down()
{
Schema::dropIfExists('orders');
}
};
<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\GuardWithContext;
use Illuminate\Database\Eloquent\Model;
use App\Services\PaymentGateway;
class HasValidPayment implements GuardWithContext
{
public function __construct(
private PaymentGateway $paymentGateway
) {}
public function check(Model $model, string $from, string $to, array $context = []): bool
{
// Check if amount is valid
if ($model->amount <= 0) {
return false;
}
// Validate payment method
$paymentMethod = $context['payment_method'] ?? $model->payment_method;
$allowedMethods = ['credit_card', 'paypal', 'bank_transfer', 'apple_pay'];
if (!in_array($paymentMethod, $allowedMethods)) {
return false;
}
// For credit card payments, validate with gateway
if ($paymentMethod === 'credit_card') {
$token = $context['payment_token'] ?? $model->payment_token;
return $this->paymentGateway->validateToken($token);
}
return true;
}
}
<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
class IsBlacklisted implements Guard
{
public function check(Model $model, string $from, string $to): bool
{
// Check if customer is blacklisted
return $model->customer->is_blacklisted ||
$model->customer->blacklisted_at !== null;
}
}
<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
class IsWithinBusinessHours implements Guard
{
public function check(Model $model, string $from, string $to): bool
{
$now = Carbon::now();
// Business hours: Monday-Friday, 9 AM - 5 PM
return $now->isWeekday() &&
$now->hour >= 9 &&
$now->hour < 17;
}
}
<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
class IsWithinCancellationPeriod implements Guard
{
public function check(Model $model, string $from, string $to): bool
{
// Allow cancellation within 24 hours of order creation
return $model->created_at->diffInHours(now()) <= 24;
}
}
<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\ActionWithContext;
use Illuminate\Database\Eloquent\Model;
use App\Services\PaymentGateway;
use Illuminate\Support\Facades\Log;
class ProcessPayment implements ActionWithContext
{
public function __construct(
private PaymentGateway $paymentGateway
) {}
public function execute(Model $model, string $from, string $to, array $context = []): void
{
$paymentMethod = $context['payment_method'] ?? $model->payment_method;
$paymentToken = $context['payment_token'] ?? $model->payment_token;
try {
$result = $this->paymentGateway->processPayment([
'amount' => $model->amount,
'currency' => $model->currency,
'payment_method' => $paymentMethod,
'payment_token' => $paymentToken,
'order_id' => $model->id,
'customer_id' => $model->customer_id,
]);
// Update order with payment details
$model->update([
'payment_method' => $paymentMethod,
'payment_token' => $paymentToken,
'paid_at' => now(),
]);
// Create payment record
$model->payments()->create([
'amount' => $model->amount,
'currency' => $model->currency,
'payment_method' => $paymentMethod,
'gateway_transaction_id' => $result['transaction_id'],
'status' => 'completed',
'processed_at' => now(),
]);
Log::info("Payment processed successfully for order {$model->id}");
} catch (\Exception $e) {
Log::error("Payment failed for order {$model->id}: " . $e->getMessage());
throw $e;
}
}
}
<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class ReserveInventory implements Action
{
public function execute(Model $model, string $from, string $to): void
{
DB::transaction(function () use ($model) {
foreach ($model->items as $item) {
$product = $item->product;
// Check availability one more time
if ($product->available_stock < $item->quantity) {
throw new \Exception("Insufficient stock for product {$product->name}");
}
// Reserve inventory
$product->decrement('available_stock', $item->quantity);
$product->increment('reserved_stock', $item->quantity);
// Create inventory reservation record
$product->inventoryReservations()->create([
'order_id' => $model->id,
'quantity' => $item->quantity,
'reserved_at' => now(),
]);
}
$model->update(['inventory_reserved_at' => now()]);
});
}
}
<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmationMail;
class SendConfirmationEmail implements Action
{
public function execute(Model $model, string $from, string $to): void
{
Mail::to($model->customer->email)->send(
new OrderConfirmationMail($model)
);
$model->update([
'confirmation_email_sent_at' => now()
]);
}
}
<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use App\Services\ShippingService;
class CreateShipment implements Action
{
public function __construct(
private ShippingService $shippingService
) {}
public function execute(Model $model, string $from, string $to): void
{
$shipment = $this->shippingService->createShipment([
'order_id' => $model->id,
'recipient' => [
'name' => $model->customer->name,
'email' => $model->customer->email,
'address' => $model->shipping_address,
],
'items' => $model->items->map(fn($item) => [
'name' => $item->product->name,
'quantity' => $item->quantity,
'weight' => $item->product->weight,
])->toArray(),
]);
$model->update([
'tracking_number' => $shipment['tracking_number'],
'shipped_at' => now(),
]);
// Create shipment record
$model->shipments()->create([
'carrier' => $shipment['carrier'],
'tracking_number' => $shipment['tracking_number'],
'estimated_delivery' => $shipment['estimated_delivery'],
'shipped_at' => now(),
]);
}
}
<?php
namespace App\Mail;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class OrderConfirmationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Order $order
) {}
public function build()
{
return $this->subject("Order Confirmation - #{$this->order->id}")
->view('emails.order-confirmation')
->with([
'order' => $this->order,
'customer' => $this->order->customer,
'items' => $this->order->items,
]);
}
}
<?php
namespace App\Listeners;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
use App\Services\AnalyticsService;
use App\Services\WebhookService;
class OrderStateChangedListener
{
public function __construct(
private AnalyticsService $analytics,
private WebhookService $webhooks
) {}
public function handle(StateTransitioned $event): void
{
if (!$event->model instanceof Order) {
return;
}
$order = $event->model;
$fromState = $event->fromState;
$toState = $event->toState;
// Log state change
Log::info("Order {$order->id} transitioned from {$fromState} to {$toState}");
// Track analytics
$this->analytics->track('order_state_changed', [
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'from_state' => $fromState,
'to_state' => $toState,
'amount' => $order->amount,
'transition_time' => now(),
]);
// Handle specific state changes
match ($toState) {
'paid' => $this->handlePaymentReceived($order),
'shipped' => $this->handleOrderShipped($order),
'delivered' => $this->handleOrderDelivered($order),
'cancelled' => $this->handleOrderCancelled($order),
default => null
};
// Send webhook notifications
$this->webhooks->sendOrderUpdate($order, $fromState, $toState);
}
private function handlePaymentReceived(Order $order): void
{
// Update customer metrics
$order->customer->increment('total_orders');
$order->customer->increment('total_spent', $order->amount);
// Notify warehouse
// dispatch(new NotifyWarehouseJob($order));
}
private function handleOrderShipped(Order $order): void
{
// Send tracking information
// dispatch(new SendTrackingInfoJob($order));
}
private function handleOrderDelivered(Order $order): void
{
// Request customer feedback
// dispatch(new RequestFeedbackJob($order));
// Update delivery metrics
$order->customer->increment('successful_deliveries');
}
private function handleOrderCancelled(Order $order): void
{
// Track cancellation reasons
$this->analytics->track('order_cancelled', [
'order_id' => $order->id,
'reason' => $order->cancellation_reason ?? 'unknown',
'stage' => $order->getCurrentState(),
]);
}
}
<?php
namespace Tests\Feature;
use App\Models\Order;
use App\Models\Customer;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class OrderWorkflowTest extends TestCase
{
use RefreshDatabase;
public function test_complete_order_workflow()
{
Mail::fake();
$customer = Customer::factory()->create();
$product = Product::factory()->create(['available_stock' => 10]);
$order = Order::factory()->create([
'customer_id' => $customer->id,
'amount' => 99.99,
]);
$order->items()->create([
'product_id' => $product->id,
'quantity' => 2,
'price' => 49.99,
]);
// Test payment transition
$this->assertTrue($order->canTransitionTo('pay'));
$order->transitionTo('pay', [
'payment_method' => 'credit_card',
'payment_token' => 'token_123',
]);
$this->assertEquals('paid', $order->getCurrentState());
$this->assertNotNull($order->paid_at);
Mail::assertSent(\App\Mail\OrderConfirmationMail::class);
// Test processing transition
$order->transitionTo('process');
$this->assertEquals('processing', $order->getCurrentState());
// Verify inventory was reserved
$this->assertEquals(8, $product->fresh()->available_stock);
$this->assertEquals(2, $product->fresh()->reserved_stock);
// Test shipping transition
$order->update(['tracking_number' => 'TRACK123']);
$order->transitionTo('ship');
$this->assertEquals('shipped', $order->getCurrentState());
$this->assertNotNull($order->shipped_at);
// Test delivery transition
$order->transitionTo('deliver');
$this->assertEquals('delivered', $order->getCurrentState());
$this->assertNotNull($order->delivered_at);
// Verify complete history
$history = $order->getStateHistory();
$this->assertCount(4, $history);
}
public function test_order_cancellation_flow()
{
$order = Order::factory()->create(['amount' => 50.00]);
// Can cancel from pending
$this->assertTrue($order->canTransitionTo('cancel'));
$order->transitionTo('cancel');
$this->assertEquals('cancelled', $order->getCurrentState());
}
public function test_payment_with_insufficient_funds()
{
$order = Order::factory()->create(['amount' => 0]);
$this->assertFalse($order->canTransitionTo('pay'));
$this->expectException(\Grazulex\LaravelStatecraft\Exceptions\TransitionNotAllowedException::class);
$order->transitionTo('pay');
}
public function test_cannot_ship_without_inventory()
{
$product = Product::factory()->create(['available_stock' => 0]);
$order = Order::factory()->paid()->create();
$order->items()->create([
'product_id' => $product->id,
'quantity' => 1,
]);
$this->assertFalse($order->canTransitionTo('process'));
}
}
<?php
namespace Tests\Unit\Guards;
use App\StateMachine\Guards\HasValidPayment;
use App\Models\Order;
use App\Services\PaymentGateway;
use Tests\TestCase;
class HasValidPaymentTest extends TestCase
{
public function test_valid_payment_passes()
{
$gateway = $this->mock(PaymentGateway::class);
$gateway->shouldReceive('validateToken')->andReturn(true);
$order = Order::factory()->make([
'amount' => 100.00,
'payment_method' => 'credit_card',
'payment_token' => 'valid_token',
]);
$guard = new HasValidPayment($gateway);
$this->assertTrue($guard->check($order, 'pending', 'paid'));
}
public function test_zero_amount_fails()
{
$gateway = $this->mock(PaymentGateway::class);
$order = Order::factory()->make(['amount' => 0]);
$guard = new HasValidPayment($gateway);
$this->assertFalse($guard->check($order, 'pending', 'paid'));
}
public function test_invalid_payment_method_fails()
{
$gateway = $this->mock(PaymentGateway::class);
$order = Order::factory()->make([
'amount' => 100.00,
'payment_method' => 'cryptocurrency',
]);
$guard = new HasValidPayment($gateway);
$this->assertFalse($guard->check($order, 'pending', 'paid'));
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Grazulex\LaravelStatecraft\Exceptions\TransitionNotAllowedException;
class OrderController extends Controller
{
public function pay(Request $request, Order $order)
{
$request->validate([
'payment_method' => 'required|in:credit_card,paypal,bank_transfer',
'payment_token' => 'required|string',
]);
try {
$order->transitionTo('pay', [
'payment_method' => $request->payment_method,
'payment_token' => $request->payment_token,
]);
return response()->json([
'message' => 'Payment processed successfully',
'order' => $order->fresh(),
]);
} catch (TransitionNotAllowedException $e) {
return response()->json([
'error' => 'Payment failed: ' . $e->getMessage(),
], 400);
}
}
public function cancel(Order $order)
{
if (!$order->canTransitionTo('cancel')) {
return response()->json([
'error' => 'Order cannot be cancelled at this stage',
], 400);
}
$order->transitionTo('cancel');
return response()->json([
'message' => 'Order cancelled successfully',
'order' => $order->fresh(),
]);
}
public function status(Order $order)
{
return response()->json([
'current_state' => $order->getCurrentState(),
'available_transitions' => $order->getAvailableTransitions(),
'history' => $order->getStateHistory(),
]);
}
}
# Process pending orders
php artisan statecraft:transition:batch Order \
--where="status='pending' AND created_at < NOW() - INTERVAL 24 HOUR" \
--transition=cancel
# Check order status
php artisan statecraft:debug Order 123
# Generate order workflow diagram
php artisan statecraft:diagram OrderStateMachine --output=order-workflow.md
This Order Workflow example demonstrates:
- Complex Business Logic - Multi-step approval processes with conditional logic
- External Integrations - Payment gateways, shipping services, email notifications
- Error Handling - Graceful failure management and recovery
- Event-Driven Architecture - Loosely coupled components responding to state changes
- Comprehensive Testing - Unit, integration, and feature tests
- Real-World Patterns - Common e-commerce workflow requirements
- User Subscription Example - Recurring billing workflows
- Article Publishing Example - Content approval processes
- Event Usage Example - Advanced event handling
Ready to implement? Copy this configuration and adapt it to your specific business requirements. The modular design makes it easy to customize guards, actions, and states for your use case.
π― Laravel Statecraft - Advanced State Machine Implementation for Laravel
Navigate: π Home | π¦ Installation | π Basic Guide | π YAML Config | π‘ Examples
Resources: π‘οΈ Guards & Actions | π― Events | π State History | π§ͺ Testing | π¨ Commands
π Community Resources:
Made with β€οΈ for the Laravel community β’ Contribute β’ License