Skip to content

Events System

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

🎯 Events System

Laravel Statecraft provides a comprehensive event system that allows you to listen for state changes and react accordingly. This enables you to build loosely coupled, event-driven applications.

πŸ“‘ Available Events

Laravel Statecraft fires several events during the state machine lifecycle:

StateTransitioned Event

Fired when a state transition occurs successfully:

use Grazulex\LaravelStatecraft\Events\StateTransitioned;

// Event properties
$event->model;          // The model instance
$event->fromState;      // Previous state
$event->toState;        // New state
$event->transition;     // Transition name
$event->context;        // Additional context data

StateTransitionAttempted Event

Fired before a transition is attempted:

use Grazulex\LaravelStatecraft\Events\StateTransitionAttempted;

// Event properties
$event->model;          // The model instance
$event->fromState;      // Current state
$event->toState;        // Target state
$event->transition;     // Transition name
$event->context;        // Additional context data

StateTransitionFailed Event

Fired when a transition fails (guard prevents it):

use Grazulex\LaravelStatecraft\Events\StateTransitionFailed;

// Event properties
$event->model;          // The model instance
$event->fromState;      // Current state
$event->toState;        // Target state
$event->transition;     // Transition name
$event->reason;         // Failure reason
$event->context;        // Additional context data

🎧 Creating Event Listeners

Register Listeners

Add your listeners to EventServiceProvider:

<?php

namespace App\Providers;

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

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

Basic Event Listener

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Log;

class OrderStateChangedListener
{
    public function handle(StateTransitioned $event): void
    {
        $model = $event->model;
        $fromState = $event->fromState;
        $toState = $event->toState;
        
        Log::info("Order {$model->id} transitioned from {$fromState} to {$toState}");
    }
}

Advanced Event Listener

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use App\Models\Order;
use App\Mail\OrderStatusChangedMail;
use App\Notifications\OrderUpdateNotification;

class OrderWorkflowListener
{
    public function handle(StateTransitioned $event): void
    {
        // Only handle Order model events
        if (!$event->model instanceof Order) {
            return;
        }
        
        $order = $event->model;
        $fromState = $event->fromState;
        $toState = $event->toState;
        
        // Handle specific state transitions
        match ($toState) {
            'paid' => $this->handlePaymentReceived($order),
            'shipped' => $this->handleOrderShipped($order),
            'delivered' => $this->handleOrderDelivered($order),
            'cancelled' => $this->handleOrderCancelled($order, $fromState),
            default => null
        };
        
        // Always log the state change
        $this->logStateChange($order, $fromState, $toState);
    }
    
    private function handlePaymentReceived(Order $order): void
    {
        // Send payment confirmation
        Mail::to($order->customer_email)->send(
            new OrderStatusChangedMail($order, 'Payment Received')
        );
        
        // Notify warehouse for processing
        Notification::route('slack', config('services.slack.warehouse_channel'))
            ->notify(new OrderUpdateNotification($order, 'Ready for processing'));
    }
    
    private function handleOrderShipped(Order $order): void
    {
        // Send tracking information
        Mail::to($order->customer_email)->send(
            new OrderStatusChangedMail($order, 'Order Shipped', [
                'tracking_number' => $order->tracking_number
            ])
        );
    }
    
    private function handleOrderDelivered(Order $order): void
    {
        // Request feedback
        Mail::to($order->customer_email)->send(
            new OrderStatusChangedMail($order, 'Order Delivered - Request Feedback')
        );
        
        // Update customer metrics
        $order->customer->increment('completed_orders');
    }
    
    private function handleOrderCancelled(Order $order, string $fromState): void
    {
        // Send cancellation notice
        Mail::to($order->customer_email)->send(
            new OrderStatusChangedMail($order, 'Order Cancelled')
        );
        
        // If order was paid, initiate refund process
        if (in_array($fromState, ['paid', 'processing'])) {
            // Trigger refund workflow
            dispatch(new ProcessRefundJob($order));
        }
    }
    
    private function logStateChange(Order $order, string $from, string $to): void
    {
        $order->auditLogs()->create([
            'event' => 'state_changed',
            'from_state' => $from,
            'to_state' => $to,
            'user_id' => auth()->id(),
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'timestamp' => now()
        ]);
    }
}

πŸ”„ Model-Specific Listeners

Using Model Events

You can also listen directly on your model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Grazulex\LaravelStatecraft\Traits\HasStateMachine;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;

class Order extends Model
{
    use HasStateMachine;
    
    protected $stateMachine = 'OrderStateMachine';
    
    protected static function booted()
    {
        // Listen for state transitions on this model only
        static::observe(OrderObserver::class);
    }
}

Model Observer

<?php

namespace App\Observers;

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

class OrderObserver
{
    public function stateTransitioned(Order $order, StateTransitioned $event): void
    {
        // Handle state transitions specific to Order model
        
        if ($event->toState === 'paid') {
            // Update inventory reservations
            $this->reserveInventory($order);
        }
        
        if ($event->toState === 'cancelled') {
            // Release inventory reservations
            $this->releaseInventory($order);
        }
    }
    
    private function reserveInventory(Order $order): void
    {
        foreach ($order->items as $item) {
            $item->product->decrement('available_quantity', $item->quantity);
            $item->product->increment('reserved_quantity', $item->quantity);
        }
    }
    
    private function releaseInventory(Order $order): void
    {
        foreach ($order->items as $item) {
            $item->product->increment('available_quantity', $item->quantity);
            $item->product->decrement('reserved_quantity', $item->quantity);
        }
    }
}

πŸ“’ Broadcasting Events

Enable Broadcasting

Configure broadcasting in your config/statecraft.php:

'events' => [
    'enabled' => true,
    'broadcast' => true,
],

Custom Broadcast Event

<?php

namespace App\Events;

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

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

    public function __construct(
        public Order $order,
        public string $fromState,
        public string $toState
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('orders.' . $this->order->customer_id),
            new Channel('orders.public')
        ];
    }
    
    public function broadcastAs(): string
    {
        return 'order.status.updated';
    }
    
    public function broadcastWith(): array
    {
        return [
            'order_id' => $this->order->id,
            'from_state' => $this->fromState,
            'to_state' => $this->toState,
            'updated_at' => $this->order->updated_at->toISOString()
        ];
    }
}

Broadcast from your listener:

public function handle(StateTransitioned $event): void
{
    if ($event->model instanceof Order) {
        broadcast(new OrderStatusUpdated(
            $event->model,
            $event->fromState,
            $event->toState
        ));
    }
}

πŸ”Œ Webhook Integration

Send Webhooks on State Changes

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class WebhookNotificationListener
{
    public function handle(StateTransitioned $event): void
    {
        $model = $event->model;
        
        // Only send webhooks for specific models
        if (!$this->shouldSendWebhook($model)) {
            return;
        }
        
        $webhookUrl = $this->getWebhookUrl($model);
        
        if (!$webhookUrl) {
            return;
        }
        
        $payload = [
            'event' => 'state_changed',
            'model' => get_class($model),
            'model_id' => $model->getKey(),
            'from_state' => $event->fromState,
            'to_state' => $event->toState,
            'transition' => $event->transition,
            'timestamp' => now()->toISOString(),
            'data' => $model->toArray()
        ];
        
        try {
            $response = Http::timeout(10)
                ->withHeaders([
                    'X-Webhook-Signature' => $this->generateSignature($payload),
                    'Content-Type' => 'application/json'
                ])
                ->post($webhookUrl, $payload);
                
            if ($response->successful()) {
                Log::info("Webhook sent successfully for {$model->getKey()}");
            } else {
                Log::warning("Webhook failed for {$model->getKey()}: " . $response->status());
            }
            
        } catch (\Exception $e) {
            Log::error("Webhook error for {$model->getKey()}: " . $e->getMessage());
        }
    }
    
    private function shouldSendWebhook($model): bool
    {
        // Configure which models should send webhooks
        return in_array(get_class($model), [
            \App\Models\Order::class,
            \App\Models\Subscription::class
        ]);
    }
    
    private function getWebhookUrl($model): ?string
    {
        // Get webhook URL based on model or customer settings
        return $model->customer->webhook_url ?? config('webhooks.default_url');
    }
    
    private function generateSignature(array $payload): string
    {
        return hash_hmac('sha256', json_encode($payload), config('webhooks.secret'));
    }
}

πŸ§ͺ Testing Events

Testing Event Firing

<?php

namespace Tests\Feature;

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

class OrderStateEventsTest extends TestCase
{
    public function test_state_transition_fires_event()
    {
        Event::fake();
        
        $order = Order::factory()->create(['status' => 'pending']);
        
        $order->transitionTo('pay');
        
        Event::assertDispatched(StateTransitioned::class, function ($event) use ($order) {
            return $event->model->is($order) &&
                   $event->fromState === 'pending' &&
                   $event->toState === 'paid';
        });
    }
    
    public function test_failed_transition_fires_failed_event()
    {
        Event::fake();
        
        $order = Order::factory()->create(['status' => 'pending']);
        
        // Try invalid transition
        try {
            $order->transitionTo('delivered');
        } catch (\Exception $e) {
            // Expected to fail
        }
        
        Event::assertDispatched(StateTransitionFailed::class);
    }
}

Testing Event Listeners

<?php

namespace Tests\Unit\Listeners;

use App\Listeners\OrderStateChangedListener;
use App\Models\Order;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class OrderStateChangedListenerTest extends TestCase
{
    public function test_sends_email_when_order_is_shipped()
    {
        Mail::fake();
        
        $order = Order::factory()->create([
            'customer_email' => '[email protected]'
        ]);
        
        $event = new StateTransitioned(
            $order,
            'processing',
            'shipped',
            'ship',
            []
        );
        
        $listener = new OrderStateChangedListener();
        $listener->handle($event);
        
        Mail::assertSent(OrderStatusChangedMail::class);
    }
}

πŸ”§ Advanced Event Patterns

Conditional Event Handling

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;

class ConditionalOrderListener
{
    public function handle(StateTransitioned $event): void
    {
        $order = $event->model;
        
        // Only handle events during business hours
        if (!$this->isBusinessHours()) {
            // Queue for later processing
            dispatch(new ProcessOrderStateChangeJob($event))->delay(now()->addHours(1));
            return;
        }
        
        // Handle immediately
        $this->processStateChange($order, $event->fromState, $event->toState);
    }
    
    private function isBusinessHours(): bool
    {
        $now = now();
        return $now->hour >= 9 && $now->hour < 17 && $now->isWeekday();
    }
}

Event Aggregation

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Cache;

class StateChangeAggregator
{
    public function handle(StateTransitioned $event): void
    {
        $model = $event->model;
        $cacheKey = "state_changes_{$model->getTable()}_{$event->toState}";
        
        // Aggregate state changes for analytics
        Cache::increment($cacheKey, 1);
        Cache::expire($cacheKey, now()->addDay());
        
        // Trigger alerts for unusual patterns
        $count = Cache::get($cacheKey, 0);
        
        if ($count > 100) { // Threshold for alerts
            // Send alert for high volume of state changes
            dispatch(new SendAlertJob("High volume of {$event->toState} transitions"));
        }
    }
}

πŸ“Š Event Analytics

Track State Transition Metrics

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Models\StateTransitionMetric;

class StateTransitionAnalytics
{
    public function handle(StateTransitioned $event): void
    {
        StateTransitionMetric::create([
            'model_type' => get_class($event->model),
            'model_id' => $event->model->getKey(),
            'from_state' => $event->fromState,
            'to_state' => $event->toState,
            'transition_name' => $event->transition,
            'user_id' => auth()->id(),
            'occurred_at' => now(),
            'context' => $event->context
        ]);
    }
}

πŸš€ What's Next?

Now that you understand the Events System:

  1. Learn about State History - Track and audit all state changes
  2. Explore Console Commands - Use CLI tools for management
  3. See Real Examples - Complete workflow implementations
  4. Test Your Events - Ensure events work correctly

Ready to implement events? Check out our Event Usage Example for a complete implementation.

Clone this wiki locally