Skip to content

Event Usage Example

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

🎯 Event Usage Example

This example demonstrates advanced event handling and integration patterns with Laravel Statecraft. It shows how to build event-driven architectures, implement real-time notifications, integrate with external systems, and create comprehensive audit trails.

🎯 Overview

The Event Usage Example covers:

  1. Real-time Notifications - Broadcast state changes to users
  2. Webhook Integrations - Send updates to external systems
  3. Analytics & Metrics - Track state machine performance
  4. Audit Logging - Comprehensive change tracking
  5. External System Integration - Connect with third-party services
  6. Custom Event Handling - Create domain-specific event patterns

πŸ“‹ YAML Configuration

# resources/state-machines/EventDemoStateMachine.yaml
name: EventDemoStateMachine
model: App\Models\EventDemo
initial_state: created
field: status
description: "Demonstration of event-driven state machine patterns"

states:
  - name: created
    description: Item has been created
    metadata:
      color: blue
      
  - name: processing
    description: Item is being processed
    metadata:
      color: yellow
      
  - name: completed
    description: Item processing completed
    metadata:
      color: green
      final: true
      
  - name: failed
    description: Item processing failed
    metadata:
      color: red
      final: true

transitions:
  - name: start_processing
    from: created
    to: processing
    action:
      - StartProcessingAction
      - NotifyStartedAction
    metadata:
      event_tags: ["processing", "started"]
      
  - name: complete
    from: processing
    to: completed
    action:
      - CompleteProcessingAction
      - NotifyCompletedAction
      - UpdateMetricsAction
    metadata:
      event_tags: ["processing", "completed", "success"]
      
  - name: fail
    from: processing
    to: failed
    action:
      - FailProcessingAction
      - NotifyFailedAction
      - LogErrorAction
    metadata:
      event_tags: ["processing", "failed", "error"]

🎧 Event Listeners

Comprehensive State Change Listener

<?php

namespace App\Listeners;

use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use App\Services\NotificationService;
use App\Services\AnalyticsService;
use App\Services\WebhookService;
use App\Services\AuditService;
use Illuminate\Support\Facades\Log;

class StateChangeEventListener
{
    public function __construct(
        private NotificationService $notifications,
        private AnalyticsService $analytics,
        private WebhookService $webhooks,
        private AuditService $audit
    ) {}

    public function handle(StateTransitioned $event): void
    {
        $model = $event->model;
        $fromState = $event->fromState;
        $toState = $event->toState;
        $transition = $event->transition;
        $context = $event->context;

        // Log the state change
        Log::info("State transition occurred", [
            'model' => get_class($model),
            'model_id' => $model->getKey(),
            'from_state' => $fromState,
            'to_state' => $toState,
            'transition' => $transition,
            'user_id' => auth()->id(),
            'timestamp' => now(),
        ]);

        // Track analytics
        $this->trackAnalytics($model, $fromState, $toState, $transition);

        // Send real-time notifications
        $this->sendRealTimeNotifications($model, $fromState, $toState);

        // Send webhooks to external systems
        $this->sendWebhooks($model, $fromState, $toState, $context);

        // Create audit trail
        $this->createAuditTrail($model, $fromState, $toState, $transition, $context);

        // Handle model-specific events
        $this->handleModelSpecificEvents($model, $fromState, $toState);
    }

    private function trackAnalytics($model, $fromState, $toState, $transition): void
    {
        $this->analytics->track('state_transition', [
            'model_type' => get_class($model),
            'model_id' => $model->getKey(),
            'from_state' => $fromState,
            'to_state' => $toState,
            'transition_name' => $transition,
            'user_id' => auth()->id(),
            'session_id' => session()->getId(),
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'occurred_at' => now(),
        ]);

        // Track state durations
        if ($model->stateHistory()->exists()) {
            $lastChange = $model->stateHistory()
                ->orderBy('transitioned_at', 'desc')
                ->skip(1)
                ->first();

            if ($lastChange) {
                $duration = $lastChange->transitioned_at->diffInSeconds(now());
                
                $this->analytics->track('state_duration', [
                    'model_type' => get_class($model),
                    'state' => $fromState,
                    'duration_seconds' => $duration,
                ]);
            }
        }
    }

    private function sendRealTimeNotifications($model, $fromState, $toState): void
    {
        // Broadcast to model owner
        if (method_exists($model, 'user') && $model->user) {
            $this->notifications->sendToUser($model->user, [
                'type' => 'state_changed',
                'title' => 'Status Updated',
                'message' => "Your {$this->getModelDisplayName($model)} changed from {$fromState} to {$toState}",
                'model_type' => get_class($model),
                'model_id' => $model->getKey(),
                'new_state' => $toState,
            ]);
        }

        // Broadcast to administrators
        if ($this->isImportantTransition($fromState, $toState)) {
            $this->notifications->sendToAdmins([
                'type' => 'important_state_change',
                'title' => 'Important Status Change',
                'message' => "{$this->getModelDisplayName($model)} #{$model->getKey()} changed to {$toState}",
                'model_type' => get_class($model),
                'model_id' => $model->getKey(),
                'requires_attention' => $toState === 'failed',
            ]);
        }
    }

    private function sendWebhooks($model, $fromState, $toState, $context): void
    {
        $payload = [
            'event' => 'state_transition',
            'model' => [
                'type' => get_class($model),
                'id' => $model->getKey(),
                'data' => $model->toArray(),
            ],
            'transition' => [
                'from' => $fromState,
                'to' => $toState,
                'context' => $context,
                'occurred_at' => now()->toISOString(),
            ],
            'user' => auth()->user()?->only(['id', 'name', 'email']),
        ];

        // Send to configured webhooks
        $this->webhooks->send('state_transition', $payload);

        // Send to model-specific webhooks
        if (method_exists($model, 'getWebhookUrls')) {
            foreach ($model->getWebhookUrls() as $url) {
                $this->webhooks->sendTo($url, $payload);
            }
        }
    }

    private function createAuditTrail($model, $fromState, $toState, $transition, $context): void
    {
        $this->audit->log([
            'event_type' => 'state_transition',
            'model_type' => get_class($model),
            'model_id' => $model->getKey(),
            'from_state' => $fromState,
            'to_state' => $toState,
            'transition_name' => $transition,
            'context' => $context,
            'user_id' => auth()->id(),
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'metadata' => [
                'model_snapshot' => $model->toArray(),
                'route' => request()->route()?->getName(),
                'request_id' => request()->header('X-Request-ID'),
            ],
        ]);
    }

    private function handleModelSpecificEvents($model, $fromState, $toState): void
    {
        // Handle different model types with specific logic
        match (get_class($model)) {
            \App\Models\Order::class => $this->handleOrderEvents($model, $fromState, $toState),
            \App\Models\Article::class => $this->handleArticleEvents($model, $fromState, $toState),
            \App\Models\Subscription::class => $this->handleSubscriptionEvents($model, $fromState, $toState),
            default => null,
        };
    }

    private function handleOrderEvents($order, $fromState, $toState): void
    {
        match ($toState) {
            'paid' => $this->handleOrderPaid($order),
            'shipped' => $this->handleOrderShipped($order),
            'delivered' => $this->handleOrderDelivered($order),
            'cancelled' => $this->handleOrderCancelled($order),
            default => null,
        };
    }

    private function handleOrderPaid($order): void
    {
        // Update customer lifetime value
        $order->customer->increment('lifetime_value', $order->amount);
        
        // Trigger inventory allocation
        dispatch(\App\Jobs\AllocateInventoryJob::class, $order);
        
        // Send to fulfillment system
        $this->webhooks->send('order_paid', [
            'order_id' => $order->id,
            'customer_id' => $order->customer_id,
            'amount' => $order->amount,
            'items' => $order->items->toArray(),
        ]);
    }

    private function isImportantTransition($fromState, $toState): bool
    {
        $importantStates = ['failed', 'cancelled', 'suspended', 'rejected'];
        return in_array($toState, $importantStates);
    }

    private function getModelDisplayName($model): string
    {
        return match (get_class($model)) {
            \App\Models\Order::class => 'order',
            \App\Models\Article::class => 'article',
            \App\Models\Subscription::class => 'subscription',
            default => 'item',
        };
    }
}

πŸ“‘ Real-Time Broadcasting

Broadcasting State Changes

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

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

    public function __construct(
        public $model,
        public string $fromState,
        public string $toState,
        public string $transition,
        public array $context = []
    ) {}

    public function broadcastOn(): array
    {
        $channels = [
            // Public channel for general updates
            new Channel('state-changes'),
        ];

        // Private channel for model owner
        if (method_exists($this->model, 'user') && $this->model->user) {
            $channels[] = new PrivateChannel('user.' . $this->model->user->id);
        }

        // Admin channel for important changes
        if ($this->isImportantChange()) {
            $channels[] = new PrivateChannel('admin-notifications');
        }

        return $channels;
    }

    public function broadcastAs(): string
    {
        return 'model.state.changed';
    }

    public function broadcastWith(): array
    {
        return [
            'model_type' => get_class($this->model),
            'model_id' => $this->model->getKey(),
            'from_state' => $this->fromState,
            'to_state' => $this->toState,
            'transition' => $this->transition,
            'timestamp' => now()->toISOString(),
            'user' => auth()->user()?->only(['id', 'name']),
            'context' => $this->context,
        ];
    }

    private function isImportantChange(): bool
    {
        return in_array($this->toState, ['failed', 'cancelled', 'suspended']);
    }
}

Frontend JavaScript Integration

// resources/js/state-machine-events.js
import Echo from 'laravel-echo';

class StateMachineEventHandler {
    constructor() {
        this.setupEventListeners();
    }

    setupEventListeners() {
        // Listen to public state changes
        Echo.channel('state-changes')
            .listen('.model.state.changed', (event) => {
                this.handleStateChange(event);
            });

        // Listen to user-specific changes
        if (window.Laravel.user) {
            Echo.private(`user.${window.Laravel.user.id}`)
                .listen('.model.state.changed', (event) => {
                    this.handleUserStateChange(event);
                });
        }

        // Admin notifications
        if (window.Laravel.user?.is_admin) {
            Echo.private('admin-notifications')
                .listen('.model.state.changed', (event) => {
                    this.handleAdminNotification(event);
                });
        }
    }

    handleStateChange(event) {
        console.log('State change:', event);
        
        // Update UI elements
        this.updateModelStatus(event.model_type, event.model_id, event.to_state);
        
        // Show toast notification
        this.showNotification(`${event.model_type} #${event.model_id} changed to ${event.to_state}`);
        
        // Update progress indicators
        this.updateProgressIndicators(event);
    }

    handleUserStateChange(event) {
        // More prominent notifications for user's own models
        this.showUserNotification(event);
        
        // Update dashboard counters
        this.updateDashboardCounters(event);
        
        // Refresh relevant sections
        this.refreshModelSections(event.model_type);
    }

    handleAdminNotification(event) {
        if (['failed', 'cancelled', 'suspended'].includes(event.to_state)) {
            this.showAdminAlert(event);
        }
    }

    updateModelStatus(modelType, modelId, newState) {
        // Find and update status elements
        const statusElements = document.querySelectorAll(
            `[data-model="${modelType}"][data-model-id="${modelId}"] .status`
        );
        
        statusElements.forEach(element => {
            element.textContent = newState;
            element.className = `status status-${newState}`;
        });
    }

    showNotification(message) {
        // Implementation depends on your notification system
        // e.g., Toastr, SweetAlert, custom notifications
        if (window.toastr) {
            toastr.info(message);
        }
    }

    showUserNotification(event) {
        const message = `Your ${event.model_type} #${event.model_id} is now ${event.to_state}`;
        
        // Show as browser notification if permission granted
        if (Notification.permission === 'granted') {
            new Notification('Status Update', {
                body: message,
                icon: '/icons/notification.png'
            });
        }
        
        this.showNotification(message);
    }

    showAdminAlert(event) {
        // More prominent alert for admin attention
        if (window.Swal) {
            Swal.fire({
                title: 'Attention Required',
                text: `${event.model_type} #${event.model_id} ${event.to_state}`,
                icon: 'warning',
                confirmButtonText: 'View Details'
            });
        }
    }
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    new StateMachineEventHandler();
});

πŸ”Œ Webhook Integration

Advanced Webhook Service

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;

class WebhookService
{
    public function send(string $eventType, array $payload): void
    {
        $webhooks = $this->getWebhooksForEvent($eventType);

        foreach ($webhooks as $webhook) {
            $this->sendWebhook($webhook, $payload);
        }
    }

    public function sendTo(string $url, array $payload): void
    {
        $webhook = [
            'url' => $url,
            'secret' => config('webhooks.default_secret'),
            'retry_attempts' => 3,
            'timeout' => 30,
        ];

        $this->sendWebhook($webhook, $payload);
    }

    private function sendWebhook(array $webhook, array $payload): void
    {
        $url = $webhook['url'];
        $secret = $webhook['secret'] ?? '';
        $maxAttempts = $webhook['retry_attempts'] ?? 3;
        $timeout = $webhook['timeout'] ?? 30;

        $headers = [
            'Content-Type' => 'application/json',
            'User-Agent' => 'Laravel-Statecraft-Webhook/1.0',
            'X-Webhook-Timestamp' => now()->timestamp,
        ];

        if ($secret) {
            $headers['X-Webhook-Signature'] = $this->generateSignature($payload, $secret);
        }

        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            try {
                $response = Http::timeout($timeout)
                    ->withHeaders($headers)
                    ->post($url, $payload);

                if ($response->successful()) {
                    Log::info("Webhook sent successfully", [
                        'url' => $url,
                        'attempt' => $attempt,
                        'status' => $response->status(),
                    ]);
                    return;
                }

                Log::warning("Webhook failed", [
                    'url' => $url,
                    'attempt' => $attempt,
                    'status' => $response->status(),
                    'response' => $response->body(),
                ]);

            } catch (\Exception $e) {
                Log::error("Webhook error", [
                    'url' => $url,
                    'attempt' => $attempt,
                    'error' => $e->getMessage(),
                ]);
            }

            if ($attempt < $maxAttempts) {
                // Exponential backoff
                sleep(pow(2, $attempt - 1));
            }
        }

        // All attempts failed - queue for later retry
        $this->queueFailedWebhook($url, $payload);
    }

    private function generateSignature(array $payload, string $secret): string
    {
        return hash_hmac('sha256', json_encode($payload), $secret);
    }

    private function getWebhooksForEvent(string $eventType): array
    {
        return Cache::remember("webhooks.{$eventType}", 300, function () use ($eventType) {
            return config("webhooks.events.{$eventType}", []);
        });
    }

    private function queueFailedWebhook(string $url, array $payload): void
    {
        // Queue webhook for retry later
        dispatch(\App\Jobs\RetryWebhookJob::class, [
            'url' => $url,
            'payload' => $payload,
        ])->delay(now()->addMinutes(5));
    }
}

πŸ“Š Analytics & Metrics

State Machine Analytics Service

<?php

namespace App\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;

class StateMachineAnalyticsService
{
    public function getTransitionMetrics(string $modelType, int $days = 30): array
    {
        $cacheKey = "state_metrics.{$modelType}.{$days}";

        return Cache::remember($cacheKey, 3600, function () use ($modelType, $days) {
            $startDate = now()->subDays($days);

            return [
                'transition_counts' => $this->getTransitionCounts($modelType, $startDate),
                'state_distribution' => $this->getStateDistribution($modelType),
                'average_durations' => $this->getAverageDurations($modelType, $startDate),
                'success_rates' => $this->getSuccessRates($modelType, $startDate),
                'daily_transitions' => $this->getDailyTransitions($modelType, $startDate),
            ];
        });
    }

    private function getTransitionCounts(string $modelType, $startDate): array
    {
        return DB::table('state_machine_histories')
            ->where('model_type', $modelType)
            ->where('transitioned_at', '>=', $startDate)
            ->selectRaw('transition, COUNT(*) as count')
            ->groupBy('transition')
            ->orderBy('count', 'desc')
            ->pluck('count', 'transition')
            ->toArray();
    }

    private function getStateDistribution(string $modelType): array
    {
        $tableName = $this->getTableNameForModel($modelType);
        
        return DB::table($tableName)
            ->selectRaw('status, COUNT(*) as count')
            ->groupBy('status')
            ->pluck('count', 'status')
            ->toArray();
    }

    private function getAverageDurations(string $modelType, $startDate): array
    {
        $durations = [];
        
        $transitions = DB::table('state_machine_histories')
            ->where('model_type', $modelType)
            ->where('transitioned_at', '>=', $startDate)
            ->orderBy('model_id')
            ->orderBy('transitioned_at')
            ->get();

        $modelTransitions = $transitions->groupBy('model_id');

        foreach ($modelTransitions as $modelId => $modelTransitions) {
            $previous = null;
            
            foreach ($modelTransitions as $transition) {
                if ($previous) {
                    $duration = strtotime($transition->transitioned_at) - strtotime($previous->transitioned_at);
                    $durations[$previous->to_state][] = $duration;
                }
                $previous = $transition;
            }
        }

        return array_map(function ($stateDurations) {
            return array_sum($stateDurations) / count($stateDurations);
        }, $durations);
    }

    private function getSuccessRates(string $modelType, $startDate): array
    {
        $totalModels = DB::table($this->getTableNameForModel($modelType))
            ->where('created_at', '>=', $startDate)
            ->count();

        $successfulModels = DB::table($this->getTableNameForModel($modelType))
            ->where('created_at', '>=', $startDate)
            ->whereIn('status', ['completed', 'delivered', 'published', 'active'])
            ->count();

        $failedModels = DB::table($this->getTableNameForModel($modelType))
            ->where('created_at', '>=', $startDate)
            ->whereIn('status', ['failed', 'cancelled', 'rejected'])
            ->count();

        return [
            'total' => $totalModels,
            'successful' => $successfulModels,
            'failed' => $failedModels,
            'success_rate' => $totalModels > 0 ? ($successfulModels / $totalModels) * 100 : 0,
            'failure_rate' => $totalModels > 0 ? ($failedModels / $totalModels) * 100 : 0,
        ];
    }

    private function getDailyTransitions(string $modelType, $startDate): array
    {
        return DB::table('state_machine_histories')
            ->where('model_type', $modelType)
            ->where('transitioned_at', '>=', $startDate)
            ->selectRaw('DATE(transitioned_at) as date, COUNT(*) as count')
            ->groupBy('date')
            ->orderBy('date')
            ->pluck('count', 'date')
            ->toArray();
    }

    private function getTableNameForModel(string $modelType): string
    {
        // Convert model class to table name
        $model = new $modelType;
        return $model->getTable();
    }
}

πŸ§ͺ Testing Event Integration

Event Testing

<?php

namespace Tests\Feature;

use App\Models\EventDemo;
use App\Events\ModelStateChanged;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class EventIntegrationTest extends TestCase
{
    public function test_state_change_fires_events()
    {
        Event::fake();
        
        $model = EventDemo::factory()->create();
        $model->transitionTo('start_processing');

        // Assert Laravel Statecraft event was fired
        Event::assertDispatched(StateTransitioned::class, function ($event) use ($model) {
            return $event->model->is($model) &&
                   $event->fromState === 'created' &&
                   $event->toState === 'processing';
        });

        // Assert custom event was fired
        Event::assertDispatched(ModelStateChanged::class);
    }

    public function test_webhooks_are_sent_on_state_change()
    {
        Http::fake();
        
        config(['webhooks.events.state_transition' => [
            ['url' => 'https://external-api.com/webhook', 'secret' => 'secret123']
        ]]);

        $model = EventDemo::factory()->create();
        $model->transitionTo('complete');

        Http::assertSent(function ($request) {
            return $request->url() === 'https://external-api.com/webhook' &&
                   $request->hasHeader('X-Webhook-Signature');
        });
    }

    public function test_analytics_are_tracked()
    {
        $model = EventDemo::factory()->create();
        
        // Transition through multiple states
        $model->transitionTo('start_processing');
        $model->transitionTo('complete');

        // Check analytics were recorded
        $this->assertDatabaseHas('analytics_events', [
            'event_type' => 'state_transition',
            'model_type' => EventDemo::class,
            'model_id' => $model->id,
        ]);
    }
}

🎯 Key Features Demonstrated

This Event Usage example showcases:

  1. Comprehensive Event Handling - Multiple event types and listeners
  2. Real-time Broadcasting - Live updates to frontend applications
  3. Webhook Integration - Reliable external system notifications
  4. Analytics & Metrics - Performance tracking and insights
  5. Audit Trails - Complete change history and compliance
  6. Error Handling - Robust failure recovery and retry logic
  7. Frontend Integration - JavaScript event handling patterns

πŸ”— Related Examples


Perfect for: Event-driven architectures, real-time applications, audit systems, integration platforms, and any application requiring comprehensive state change tracking and notifications.

Clone this wiki locally