Skip to content

Order Workflow Example

Jean-Marc Strauven edited this page Aug 6, 2025 · 2 revisions

πŸ“¦ 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.

🎯 Overview

The Order Workflow manages the complete lifecycle of an e-commerce order:

  1. Order Creation - Customer creates an order (draft β†’ pending)
  2. Payment Processing - Payment validation and processing (pending β†’ paid)
  3. Order Fulfillment - Inventory reservation and processing (paid β†’ processing)
  4. Shipping - Package preparation and shipment (processing β†’ shipped)
  5. Delivery - Final delivery confirmation (shipped β†’ delivered)
  6. Cancellation - Handle order cancellations at any stage

πŸ“‹ YAML Configuration

# 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

πŸ—οΈ Model Setup

Order Model

<?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']);
    }
}

Database Migration

<?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');
    }
};

πŸ›‘οΈ Guards Implementation

HasValidPayment Guard

<?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;
    }
}

IsBlacklisted Guard

<?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;
    }
}

IsWithinBusinessHours Guard

<?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;
    }
}

IsWithinCancellationPeriod Guard

<?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;
    }
}

βš™οΈ Actions Implementation

ProcessPayment Action

<?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;
        }
    }
}

ReserveInventory Action

<?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()]);
        });
    }
}

SendConfirmationEmail Action

<?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()
        ]);
    }
}

CreateShipment Action

<?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(),
        ]);
    }
}

πŸ“§ Email Templates

Order Confirmation Mail

<?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,
                    ]);
    }
}

🎯 Event Listeners

Order Event Listener

<?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(),
        ]);
    }
}

πŸ§ͺ Comprehensive Tests

Feature Tests

<?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'));
    }
}

Unit Tests for Guards

<?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'));
    }
}

πŸ“Š Usage Examples

Controller Integration

<?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(),
        ]);
    }
}

Command Line Usage

# 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

πŸš€ Key Takeaways

This Order Workflow example demonstrates:

  1. Complex Business Logic - Multi-step approval processes with conditional logic
  2. External Integrations - Payment gateways, shipping services, email notifications
  3. Error Handling - Graceful failure management and recovery
  4. Event-Driven Architecture - Loosely coupled components responding to state changes
  5. Comprehensive Testing - Unit, integration, and feature tests
  6. Real-World Patterns - Common e-commerce workflow requirements

πŸ”— Related Examples


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.

Clone this wiki locally