Event-driven architectuur in Laravel — orders, voorraad, notificaties
Terug naar blog

Event-driven architectuur in Laravel — orders, voorraad, notificaties

AuthorRuthger Idema
16 april 202612 min leestijd

Een order wordt geplaatst. Tegelijk moet de voorraad worden gereserveerd, een bevestigingsmail verstuurd, de boekhouding bijgewerkt en het ERP gesynchroniseerd. Event-driven architectuur maakt dit schaalbaar.

Event-driven architectuur in Laravel — orders, voorraad, notificaties

Een order wordt geplaatst. Op dat moment moeten 6 dingen tegelijk gebeuren: voorraad reserveren, bevestigingsmail sturen, boekhouding bijwerken, ERP synchroniseren, accountmanager notificeren, fraudecheck uitvoeren.

Als je dit allemaal in één controller method zet, heb je binnen drie maanden een functie van 200 regels die niemand meer durft aan te raken. Event-driven architectuur lost dit op door elke verantwoordelijkheid te scheiden.

Wat je leert in dit artikel

  • Hoe events en listeners werken in Laravel
  • Concrete e-commerce use cases: orders, voorraad, notificaties
  • Observers voor Eloquent model events
  • Broadcasting voor real-time updates in dashboards
  • Queued listeners voor langzame operaties
  • Event sourcing als volgende stap

Het principe

Een event is een feit dat heeft plaatsgevonden. OrderPlaced, PaymentConfirmed, StockDepleted. Het event weet niet wat er daarna gebeurt. Het meldt alleen dat iets is gebeurd.

Listeners reageren op events. Ze zijn onafhankelijk van elkaar. Je kunt een listener toevoegen of verwijderen zonder de code te veranderen die het event dispatcht.

OrderController::store()
    → dispatch(new OrderPlaced($order))
        → SendOrderConfirmationEmail (queued)
        → ReserveInventory (sync)
        → NotifyAccountManager (queued)
        → SyncToErp (queued)
        → CheckForFraud (queued)

De controller hoeft niet te weten wat er met de order gebeurt. Hij meldt alleen dat een order is geplaatst.

Events definiëren

php
// app/Events/OrderPlaced.php
namespace App\Events;

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

class OrderPlaced
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly Order $order,
        public readonly string $source = 'web', // 'web', 'api', 'import'
    ) {}
}
php
// app/Events/PaymentConfirmed.php
class PaymentConfirmed
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly Order $order,
        public readonly string $paymentMethod,
        public readonly float $amount,
        public readonly string $transactionId,
    ) {}
}
php
// app/Events/StockDepleted.php
class StockDepleted
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public readonly \App\Models\Product $product,
        public readonly int $warehouseId,
    ) {}
}

Listeners registreren

php
// app/Providers/EventServiceProvider.php
protected $listen = [
    \App\Events\OrderPlaced::class => [
        \App\Listeners\ReserveInventory::class,          // Sync — moet direct
        \App\Listeners\SendOrderConfirmationEmail::class, // Async — mag wachten
        \App\Listeners\NotifyAccountManager::class,       // Async
        \App\Listeners\SyncOrderToErp::class,             // Async
        \App\Listeners\CheckOrderForFraud::class,         // Async
    ],
    \App\Events\PaymentConfirmed::class => [
        \App\Listeners\UpdateOrderStatus::class,
        \App\Listeners\TriggerFulfillment::class,
        \App\Listeners\SendPaymentReceiptEmail::class,
        \App\Listeners\UpdateAccountingSystem::class,
    ],
    \App\Events\StockDepleted::class => [
        \App\Listeners\NotifyPurchasingDepartment::class,
        \App\Listeners\HideProductFromStorefront::class,
        \App\Listeners\CreateReplenishmentOrder::class,
    ],
];

Listeners implementeren

Synchrone listeners — voor operaties die direct moeten plaatsvinden:

php
// app/Listeners/ReserveInventory.php
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Services\InventoryService;

class ReserveInventory
{
    public function __construct(private InventoryService $inventoryService) {}

    public function handle(OrderPlaced $event): void
    {
        foreach ($event->order->lines as $line) {
            $this->inventoryService->reserve(
                productId: $line->product_id,
                quantity: $line->quantity,
                orderId: $event->order->id,
            );
        }
    }
}

Asynchrone listeners — voor operaties die mogen wachten:

php
// app/Listeners/SyncOrderToErp.php
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Services\ErpSyncService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class SyncOrderToErp implements ShouldQueue
{
    use InteractsWithQueue;

    // Welke queue gebruiken
    public string $queue = 'erp-sync';

    // Maximaal 3 pogingen bij falen
    public int $tries = 3;

    // Wacht 60 seconden voor herpoging
    public int $backoff = 60;

    public function __construct(private ErpSyncService $erpService) {}

    public function handle(OrderPlaced $event): void
    {
        $this->erpService->pushOrder($event->order);
    }

    // Wat te doen als alle pogingen mislukken
    public function failed(OrderPlaced $event, \Throwable $exception): void
    {
        \Log::error('ERP sync mislukt na 3 pogingen', [
            'order_id' => $event->order->id,
            'error'    => $exception->getMessage(),
        ]);

        // Notificeer het team
        \Notification::route('slack', config('services.slack.ops_webhook'))
            ->notify(new ErpSyncFailed($event->order, $exception));
    }
}

Observers voor Eloquent model events

Observers zijn een specialisatie van het event patroon voor Eloquent models. Ze luisteren naar database events: creating, created, updating, updated, deleting, deleted.

php
// app/Observers/OrderObserver.php
namespace App\Observers;

use App\Events\OrderPlaced;
use App\Events\OrderStatusChanged;
use App\Models\Order;

class OrderObserver
{
    public function created(Order $order): void
    {
        // Dispatch het event zodra de order in de database staat
        OrderPlaced::dispatch($order);
    }

    public function updated(Order $order): void
    {
        // Alleen dispatchen als de status daadwerkelijk is veranderd
        if ($order->wasChanged('status')) {
            OrderStatusChanged::dispatch(
                order: $order,
                oldStatus: $order->getOriginal('status'),
                newStatus: $order->status,
            );
        }
    }

    public function deleted(Order $order): void
    {
        // Reservering vrijgeven bij het verwijderen van een order
        foreach ($order->lines as $line) {
            \App\Events\InventoryReservationReleased::dispatch($line);
        }
    }
}
php
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Order::observe(OrderObserver::class);
    Product::observe(ProductObserver::class);
}

Broadcasting voor real-time dashboards

Events kunnen ook naar de browser worden gestuurd via WebSockets. Accountmanagers zien nieuwe orders zonder de pagina te verversen.

php
// app/Events/OrderPlaced.php — broadcasting toevoegen
class OrderPlaced implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public readonly Order $order) {}

    public function broadcastOn(): array
    {
        return [
            // Privékanaal voor de accountmanager van de klant
            new PrivateChannel("account-manager.{$this->order->customer->account_manager_id}"),
            // Adminkanaal voor alle orders
            new PrivateChannel('admin.orders'),
        ];
    }

    public function broadcastWith(): array
    {
        // Stuur alleen de data die de frontend nodig heeft
        return [
            'order_id'      => $this->order->id,
            'reference'     => $this->order->reference,
            'customer_name' => $this->order->customer->name,
            'total'         => $this->order->total,
            'created_at'    => $this->order->created_at->toISOString(),
        ];
    }

    // Naam van het event in de frontend
    public function broadcastAs(): string
    {
        return 'order.placed';
    }
}
javascript
// Frontend: luisteren naar nieuwe orders
Echo.private(`account-manager.${accountManagerId}`)
    .listen('.order.placed', (data) => {
        // Voeg order toe aan de tabel zonder pagina te herladen
        orderTable.prepend(data);
        showNotification(`Nieuwe order: ${data.reference}`);
    });

Concrete e-commerce flows

Voorraad beheer flow

StockReceived
    → UpdateInventoryLevels (sync)
    → ReactivateOutOfStockProducts (queued)
    → NotifyWaitingCustomers (queued)
    → UpdateSearchIndex (queued)

StockDepleted
    → HideProductFromStorefront (sync)
    → NotifyPurchasingDepartment (queued)
    → CancelBackorders (queued)

Betaling flow

PaymentInitiated
    → CreatePendingPaymentRecord (sync)
    → LogPaymentAttempt (queued)

PaymentConfirmed
    → UpdateOrderStatus (sync)
    → TriggerFulfillment (queued)
    → SendReceiptEmail (queued)
    → UpdateAccounting (queued)

PaymentFailed
    → UpdateOrderStatus (sync)
    → NotifyCustomer (queued)
    → ReleaseInventoryReservation (queued)
    → LogFailedPayment (queued)

Verzending flow

OrderShipped
    → UpdateOrderStatus (sync)
    → SendTrackingEmail (queued)
    → NotifyAccountManager (queued)
    → UpdateErp (queued)

OrderDelivered (webhook van vervoerder)
    → UpdateOrderStatus (sync)
    → TriggerReviewRequest (queued, delay 3 dagen)
    → UpdateCustomerMetrics (queued)

Veelgemaakte fouten

  1. Listeners te zwaar maken — Een listener doet één ding. Te veel logica in een listener maakt het moeilijk te testen. Delegeer zware logica naar services.
  2. Sync listeners voor trage operaties — API calls naar ERP, e-mail versturen, PDF genereren — dit doet altijd async. Sync listeners verlengen de response tijd van je controller.
  3. Events niet loggen — Bij een mislukte ERP sync wil je weten welke order niet is gesynchroniseerd. Log events bij critical operaties.
  4. Queue workers vergeten te herstarten na deploy — Queued listeners worden in memory geladen. Na een deploy draaien workers nog met de oude code. Altijd php artisan queue:restart bij deploy.
bash
# In je deploy script
php artisan queue:restart
php artisan horizon:terminate # Als je Horizon gebruikt

Event sourcing als volgende stap

Als je alle events opslaat in plaats van alleen de huidige state, heb je event sourcing. Je kunt dan de volledige ordergeschiedenis reconstrueren, terug in de tijd kijken en historische rapporten genereren.

Voor de meeste e-commerce projecten is dit overkill. Maar voor platforms met complexe auditbehoeften — financiële diensten, medische supplies, overheidsklanten — is het de moeite waard te verkennen via het spatie/laravel-event-sourcing package.

Conclusie

Event-driven architectuur maakt je e-commerce backend modulair en uitbreidbaar. Een nieuwe functie toevoegen betekent een nieuwe listener schrijven, niet een bestaande controller aanpassen. Dat is het verschil tussen code die groeit en code die verstikt.

Begin met de flows die de meeste koppelingen hebben: order aanmaken, betaling bevestigen, verzending. De rest volgt vanzelf als het patroon eenmaal zit.

Laravel's Events en Listeners documentatie en het Spatie Event Sourcing package zijn de referenties voor de patronen in dit artikel. Het Observer pattern is de basis van event-driven architectuur.

Gerelateerde artikelen:

Bekijk onze Laravel-diensten of neem contact op voor een architectuurgesprek.

Ruthger Idema

Geschreven door Ruthger Idema

15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.

Meer over ons team →
Deel dit artikel:

Wil je jouw e-commerce naar het volgende niveau?

Plan een vrijblijvend gesprek met onze experts over Magento, Shopify of Laravel maatwerk.

Plan een Tech Check