Skip to content

Events System

Jean-Marc Strauven edited this page Jul 31, 2025 · 2 revisions

🎯 Events System

Build event-driven workflows with Laravel Statecraft's comprehensive event system.

πŸ“– Overview

Laravel Statecraft dispatches Laravel events during state transitions, allowing you to hook into the workflow lifecycle and create event-driven architectures.

The event system provides:

  • Pre-transition events - Execute logic before state changes
  • Post-transition events - React to completed state changes
  • Full Laravel integration - Use Laravel's built-in event system
  • Context preservation - Access transition context and metadata

🎯 Event Types

StateTransitioning Event

Dispatched before a state transition occurs, allowing you to:

  • Perform additional validation
  • Prepare data for the transition
  • Log transition attempts
  • Cancel transitions if needed
use Grazulex\LaravelStatecraft\Events\StateTransitioning;

Event::listen(StateTransitioning::class, function (StateTransitioning $event) {
    // Access event properties
    $model = $event->model;       // The model being transitioned
    $from = $event->from;         // From state
    $to = $event->to;             // To state
    $transition = $event->transition; // Transition name
    $context = $event->context;   // Transition context data
    
    // Example: Log transition attempt
    Log::info("Attempting transition: {$from} -> {$to}", [
        'model' => get_class($model),
        'id' => $model->id,
        'transition' => $transition
    ]);
});

StateTransitioned Event

Dispatched after a state transition completes successfully:

use Grazulex\LaravelStatecraft\Events\StateTransitioned;

Event::listen(StateTransitioned::class, function (StateTransitioned $event) {
    // Transition completed successfully
    $model = $event->model;       // The model that was transitioned
    $from = $event->from;         // From state
    $to = $event->to;             // To state
    $transition = $event->transition; // Transition name
    $context = $event->context;   // Transition context data
    
    // Example: Send notification
    if ($to === 'shipped') {
        Mail::to($model->customer_email)
            ->send(new OrderShippedMail($model));
    }
});

βš™οΈ Event Configuration

Configure events in your config/statecraft.php:

return [
    'events' => [
        'enabled' => true, // Enable/disable all events
        
        // Specific event configuration
        'state_transitioning' => true,
        'state_transitioned' => true,
        
        // Event queue configuration
        'should_queue' => false, // Queue events for better performance
        'queue_connection' => 'default',
        'queue' => 'state-transitions',
    ],
];

Per-State Machine Events

You can also configure events per state machine in YAML:

name: OrderStateMachine
model: App\Models\Order
initial_state: pending

config:
  enable_events: true
  queue_events: true

states:
  # ... states definition

πŸ‘‚ Event Listeners

Creating Event Listeners

Generate a listener:

php artisan make:listener OrderStateTransitionListener

Register in EventServiceProvider:

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Grazulex\LaravelStatecraft\Events\StateTransitioning;
use App\Listeners\OrderStateTransitionListener;
use App\Listeners\OrderPreTransitionListener;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        StateTransitioned::class => [
            OrderStateTransitionListener::class,
        ],
        
        StateTransitioning::class => [
            OrderPreTransitionListener::class,
        ],
    ];
}

Comprehensive Event Listener

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Models\Order;
use App\Services\NotificationService;
use App\Services\InventoryService;
use App\Services\AuditService;
use Illuminate\Support\Facades\Log;

class OrderStateTransitionListener
{
    public function __construct(
        private NotificationService $notificationService,
        private InventoryService $inventoryService,
        private AuditService $auditService
    ) {}

    public function handle(StateTransitioned $event): void
    {
        // Only handle Order model transitions
        if (!$event->model instanceof Order) {
            return;
        }

        // Log the transition
        $this->auditService->logTransition($event);

        // Handle specific transitions
        match ($event->to) {
            'paid' => $this->handlePaymentCompleted($event),
            'processing' => $this->handleProcessingStarted($event),
            'shipped' => $this->handleOrderShipped($event),
            'delivered' => $this->handleOrderDelivered($event),
            'cancelled' => $this->handleOrderCancelled($event),
            default => null,
        };
    }

    private function handlePaymentCompleted(StateTransitioned $event): void
    {
        $order = $event->model;
        
        // Send payment confirmation
        $this->notificationService->sendPaymentConfirmation($order);
        
        // Reserve inventory
        $this->inventoryService->reserveItems($order->items);
        
        // Update order metadata
        $order->update([
            'payment_confirmed_at' => now(),
            'inventory_reserved' => true
        ]);
    }

    private function handleProcessingStarted(StateTransitioned $event): void
    {
        $order = $event->model;
        
        // Notify warehouse
        $this->notificationService->notifyWarehouse($order);
        
        // Create internal work order
        $this->createWorkOrder($order);
    }

    private function handleOrderShipped(StateTransitioned $event): void
    {
        $order = $event->model;
        $context = $event->context;
        
        // Send shipping notification with tracking
        $this->notificationService->sendShippingNotification($order, [
            'tracking_number' => $context['tracking_number'] ?? null,
            'carrier' => $context['carrier'] ?? null,
            'estimated_delivery' => $context['estimated_delivery'] ?? null
        ]);
        
        // Update inventory
        $this->inventoryService->markAsShipped($order->items);
    }

    private function handleOrderDelivered(StateTransitioned $event): void
    {
        $order = $event->model;
        
        // Send delivery confirmation
        $this->notificationService->sendDeliveryConfirmation($order);
        
        // Request review
        $this->notificationService->requestProductReview($order);
        
        // Complete inventory transaction
        $this->inventoryService->completeTransaction($order->items);
    }

    private function handleOrderCancelled(StateTransitioned $event): void
    {
        $order = $event->model;
        $context = $event->context;
        
        // Send cancellation notification
        $this->notificationService->sendCancellationNotification($order, [
            'reason' => $context['reason'] ?? 'No reason provided'
        ]);
        
        // Release inventory
        $this->inventoryService->releaseReservedItems($order->items);
        
        // Process refund if needed
        if ($order->currentState() === 'paid') {
            $this->processRefund($order);
        }
    }
}

Pre-Transition Event Listener

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioning;
use App\Models\Order;
use Illuminate\Support\Facades\Log;

class OrderPreTransitionListener
{
    public function handle(StateTransitioning $event): void
    {
        if (!$event->model instanceof Order) {
            return;
        }

        // Log transition attempt
        Log::info('Order state transition starting', [
            'order_id' => $event->model->id,
            'from' => $event->from,
            'to' => $event->to,
            'transition' => $event->transition,
            'user_id' => auth()->id(),
            'timestamp' => now()
        ]);

        // Perform pre-transition validation
        $this->validateTransition($event);
        
        // Prepare data for transition
        $this->prepareTransitionData($event);
    }

    private function validateTransition(StateTransitioning $event): void
    {
        $order = $event->model;
        
        // Example: Additional business validation
        if ($event->to === 'shipped' && !$order->shipping_address) {
            throw new \Exception('Cannot ship order without shipping address');
        }
        
        if ($event->to === 'processing' && $order->items->isEmpty()) {
            throw new \Exception('Cannot process order with no items');
        }
    }

    private function prepareTransitionData(StateTransitioning $event): void
    {
        $order = $event->model;
        
        // Example: Set transition metadata
        if ($event->to === 'processing') {
            $order->processing_started_at = now();
            $order->assigned_warehouse = $this->determineWarehouse($order);
        }
    }
}

πŸ”„ Model-Specific Events

Dedicated Event Classes

Create specific event classes for different models:

<?php

namespace App\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use App\Models\Order;

class OrderPaid
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public Order $order,
        public array $paymentData = []
    ) {}
}

Dispatching Custom Events from Actions

<?php

namespace App\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use App\Events\OrderPaid;

class ProcessPaymentAction implements Action
{
    public function execute(Model $model, array $context = []): void
    {
        // Process payment logic
        $paymentResult = $this->processPayment($model, $context);
        
        // Update model
        $model->update([
            'payment_processed_at' => now(),
            'payment_id' => $paymentResult['payment_id']
        ]);
        
        // Dispatch custom event
        event(new OrderPaid($model, $paymentResult));
    }
}

πŸ“‘ Broadcasting Events

Real-time Updates

Broadcast state transitions for real-time UI updates:

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use App\Models\Order;

class OrderStateChanged implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public Order $order,
        public string $previousState,
        public string $newState
    ) {}

    public function broadcastOn(): array
    {
        return [
            new Channel('orders'),
            new PrivateChannel('user.' . $this->order->customer_id),
        ];
    }

    public function broadcastWith(): array
    {
        return [
            'order_id' => $this->order->id,
            'previous_state' => $this->previousState,
            'new_state' => $this->newState,
            'timestamp' => now()->toISOString(),
        ];
    }
}

Broadcast Listener

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Events\OrderStateChanged;
use App\Models\Order;

class BroadcastOrderStateChangeListener
{
    public function handle(StateTransitioned $event): void
    {
        if ($event->model instanceof Order) {
            broadcast(new OrderStateChanged(
                $event->model,
                $event->from,
                $event->to
            ));
        }
    }
}

πŸ”— Event-Driven Integrations

Third-Party Service Integration

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Models\Order;
use App\Services\CrmService;
use App\Services\AnalyticsService;

class ThirdPartyIntegrationListener
{
    public function __construct(
        private CrmService $crmService,
        private AnalyticsService $analyticsService
    ) {}

    public function handle(StateTransitioned $event): void
    {
        if (!$event->model instanceof Order) {
            return;
        }

        // Update CRM
        $this->crmService->updateOrderStatus($event->model, $event->to);
        
        // Track analytics
        $this->analyticsService->trackEvent('order_state_change', [
            'order_id' => $event->model->id,
            'from_state' => $event->from,
            'to_state' => $event->to,
            'transition' => $event->transition,
            'customer_id' => $event->model->customer_id
        ]);
        
        // Webhook notifications
        if (in_array($event->to, ['paid', 'shipped', 'delivered'])) {
            $this->sendWebhookNotification($event);
        }
    }

    private function sendWebhookNotification(StateTransitioned $event): void
    {
        Http::post(config('webhooks.order_updates_url'), [
            'event' => 'order.state_changed',
            'order_id' => $event->model->id,
            'state' => $event->to,
            'timestamp' => now()->toISOString()
        ]);
    }
}

πŸ§ͺ Testing Events

Testing Event Dispatch

<?php

namespace Tests\Feature;

use App\Models\Order;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class OrderEventsTest extends TestCase
{
    public function test_state_transitioned_event_is_dispatched()
    {
        Event::fake();
        
        $order = Order::factory()->create();
        $order->transition('pay');
        
        Event::assertDispatched(StateTransitioned::class, function ($event) use ($order) {
            return $event->model->id === $order->id &&
                   $event->from === 'pending' &&
                   $event->to === 'paid' &&
                   $event->transition === 'pay';
        });
    }
    
    public function test_event_listener_handles_order_correctly()
    {
        Event::fake();
        
        $order = Order::factory()->create();
        $order->transition('pay');
        
        Event::assertDispatched(StateTransitioned::class);
        
        // Test that listener logic works
        $this->assertNotNull($order->fresh()->payment_confirmed_at);
    }
}

Testing Event Listeners

<?php

namespace Tests\Unit\Listeners;

use App\Listeners\OrderStateTransitionListener;
use App\Models\Order;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Tests\TestCase;

class OrderStateTransitionListenerTest extends TestCase
{
    public function test_handles_payment_completed()
    {
        $order = Order::factory()->create();
        $event = new StateTransitioned($order, 'pending', 'paid', 'pay');
        
        $listener = new OrderStateTransitionListener();
        $listener->handle($event);
        
        $this->assertNotNull($order->fresh()->payment_confirmed_at);
    }
}

πŸš€ Best Practices

Event Organization

  1. Separate concerns - Use different listeners for different responsibilities
  2. Queue heavy operations - Queue listeners that perform heavy operations
  3. Handle failures gracefully - Implement proper error handling
  4. Use specific events - Create custom events for complex scenarios

Performance Considerations

// Queue event listeners for better performance
class OrderStateTransitionListener implements ShouldQueue
{
    use Queueable;
    
    public function handle(StateTransitioned $event): void
    {
        // Heavy operations queued automatically
    }
}

Error Handling

class OrderStateTransitionListener
{
    public function handle(StateTransitioned $event): void
    {
        try {
            $this->processTransition($event);
        } catch (\Exception $e) {
            Log::error('Event handling failed', [
                'event' => get_class($event),
                'model_id' => $event->model->id,
                'error' => $e->getMessage()
            ]);
            
            // Optionally re-throw for critical operations
            // throw $e;
        }
    }
}

πŸš€ Next Steps

Now that you understand the event system, explore:

Core Features

Real-World Examples

Advanced Topics

Ready to track state changes? Check out πŸ“Š State History!

Clone this wiki locally