Event-Driven Architecture

Events, Listeners & Side Effects

Home | SoftLixx Creates
Architecture

Event-Driven Architecture

Decouple side effects from business logic using domain events and auto-discovered listeners.

System Overview

AlKareem ERP uses an event-driven architecture to handle side effects like GL posting, stock updates, and notifications. Events are fired from Models after approval, and Listeners react to perform the necessary operations without coupling them to the core business logic.

Domain Events

Simple data containers fired when business operations complete.

Listeners

React to events and perform side effects (GL, Stock, Notifications).

Auto-Discovery

Laravel auto-discovers listeners via union type hints.

Event Flow

sequenceDiagram
    autonumber
    participant S as Service
    participant M as Model
    participant E as Event
    participant L1 as GL Listener
    participant L2 as Stock Listener
    participant L3 as Notification Listener

    S->>M: save() + fireCreateEvent()
    M->>E: new TransferCreated($this)
    
    par Parallel Execution
        E->>L1: handle(TransferCreated)
        L1->>L1: Create GL Transactions
        
        E->>L2: handle(TransferCreated)
        L2->>L2: Update Stock Levels
        
        E->>L3: handle(TransferCreated)
        L3->>L3: Send Notifications
    end
                        

Key Principle

Events are only fired after approval. If a document requires approval, the fireCreateEvent() method is called by the ApprovalEngine when all approval steps complete, not by the Service directly.

Event Structure

Events are simple data containers that hold the relevant entity. They're organized by module in the app/Events/{Module}/ directory.

Created Event

// app/Events/Inventory/TransferCreated.php

namespace App\Events\Inventory;

class TransferCreated
{
    public function __construct(
        public Transfer $data
    ) {}
}

Updated Event

// app/Events/Inventory/TransferUpdated.php

namespace App\Events\Inventory;

class TransferUpdated
{
    public function __construct(
        public Transfer $data
    ) {}
}

Deleted Event (Special Structure)

// app/Events/Inventory/TransferDeleted.php
// Note: For delete events, pass IDs since the entity will be deleted

namespace App\Events\Inventory;

class TransferDeleted
{
    public function __construct(
        public int $transferId,
        public array $itemIds  // Include related IDs for cleanup
    ) {}
}

Directory Structure

app/Events/
├── Accounts/
│   ├── VoucherCreated.php
│   ├── VoucherUpdated.php
│   └── VoucherDeleted.php
├── Inventory/
│   ├── TransferCreated.php
│   ├── TransferUpdated.php
│   └── TransferDeleted.php
├── Purchase/
│   └── ...
└── Lease/
    └── ...

Model Event Firing

// In Model
public function fireCreateEvent(): void
{
    $this->loadMissing('items');
    event(new TransferCreated($this));
}

Listeners

Listeners react to events and perform side effects. They're organized by module in app/Listeners/{Module}/ and use union types for auto-discovery.

Multi-Event Listener Example

// app/Listeners/Inventory/ManageStockTransactionFromTransfer.php

namespace App\Listeners\Inventory;

use App\Events\Inventory\{TransferCreated, TransferUpdated, TransferDeleted};
use App\Services\Inventory\StockTransactionService;

class ManageStockTransactionFromTransfer
{
    public function __construct(
        protected StockTransactionService $stockTransactionService
    ) {}

    /**
     * Union type enables auto-discovery for all three events
     */
    public function handle(
        TransferCreated|TransferUpdated|TransferDeleted $event
    ): void {
        match (true) {
            $event instanceof TransferCreated => $this->handleCreated($event),
            $event instanceof TransferUpdated => $this->handleUpdated($event),
            $event instanceof TransferDeleted => $this->handleDeleted($event),
        };
    }

    protected function handleCreated(TransferCreated $event): void
    {
        if ($event->data->status === 'RECEIVED') {
            $this->createTransactions($event->data);
        }
    }

    protected function handleUpdated(TransferUpdated $event): void
    {
        // Reverse old transactions, create new ones
        $this->stockTransactionService->reverseForSource($event->data->id);
        $this->createTransactions($event->data);
    }

    protected function handleDeleted(TransferDeleted $event): void
    {
        $this->stockTransactionService->deleteForSource(
            $event->transferId,
            'Transfer'
        );
    }

    protected function createTransactions(Transfer $transfer): void
    {
        foreach ($transfer->items as $item) {
            // TRANSFER_OUT from source warehouse
            $this->stockTransactionService->create([
                'type' => 'TRANSFER_OUT',
                'warehouse_id' => $transfer->from_warehouse_id,
                'item_id' => $item->item_id,
                'quantity' => -$item->quantity,
            ]);
            
            // TRANSFER_IN to destination warehouse
            $this->stockTransactionService->create([
                'type' => 'TRANSFER_IN',
                'warehouse_id' => $transfer->to_warehouse_id,
                'item_id' => $item->item_id,
                'quantity' => $item->quantity,
            ]);
        }
    }
}

Common Listener Patterns

Pattern 1: GL Transaction Creation

// Creates debit/credit entries in the General Ledger

class CreateTransactionFromVoucher
{
    public function handle(VoucherCreated $event): void
    {
        foreach ($event->data->entries as $entry) {
            $this->transactionService->create([
                'account_id' => $entry->account_id,
                'debit' => $entry->debit_amount,
                'credit' => $entry->credit_amount,
                'voucher_id' => $event->data->id,
            ]);
        }
    }
}

Pattern 2: Warehouse Stock Updates

// Updates warehouse_stock_items and recalculates WAC

class UpdateWarehouseStock
{
    public function handle(
        StockTransactionCreated|StockTransactionDeleted $event
    ): void {
        $this->warehouseStockService->recalculate(
            $event->data->warehouse_id,
            $event->data->item_id
        );
    }
}

Pattern 3: Cascade to Child Events

// A listener that fires additional events

class CreateReceivingFromPurchaseInvoice
{
    public function handle(PurchaseInvoiceCreated $event): void
    {
        // Create receiving voucher
        $receiving = $this->receivingService->createFromInvoice(
            $event->data
        );
        
        // Fire its own event (triggers stock listeners)
        $receiving->fireCreateEvent();
    }
}

Laravel Auto-Discovery

Laravel automatically discovers listeners based on the type hints in their handle() method. No manual registration in EventServiceProvider is needed.

How It Works

  1. Laravel scans app/Listeners/
  2. Reads type hints on handle() method
  3. Automatically maps event → listener
  4. Union types register for multiple events

Union Type Syntax

// Single event
public function handle(TransferCreated $e)

// Multiple events (union type)
public function handle(
    TransferCreated|TransferUpdated $e
)

Clear Cache After Adding Listeners

If a new listener isn't being called, clear the event cache:

php artisan event:clear && php artisan event:cache

Module Event Summary

Module Key Events Side Effects
Accounts VoucherCreated, ExpenseCreated GL Transactions
Inventory TransferCreated, DeliveryVoucherCreated Stock Transactions, WAC Calculation
Purchase PurchaseInvoiceCreated, SupplierPaymentCreated Receiving Vouchers, AP Updates
CashSale CashSaleInvoiceCreated Delivery, Revenue Posting
Lease LeaseProcessingCreated, CollectionCreated Installment Schedules, AR Updates