Skip to content

Domain Events Guide

Overview

Domain Events represent significant occurrences in the domain that domain experts care about. They enable loose coupling between modules, implement event-driven architecture, and provide a clear audit trail of what happened in the system.

Core Concepts

What is a Domain Event?

A Domain Event is something that happened in the past:

php
ProductCreated::class
ProductActivated::class
InventoryAdjusted::class
PurchaseOrderReceived::class

Event Characteristics:

  • Past tense naming: ProductCreated (not CreateProduct)
  • Immutable: Cannot be changed after creation
  • Contains relevant data: What happened, when, and context
  • No behavior: Just data carriers

Event-Driven Architecture

mermaid
graph LR
    A[Product Activated] --> B[Search Index Updated]
    A --> C[Catalog Published]
    A --> D[Sales Team Notified]
    A --> E[Analytics Recorded]

Benefits:

  • Loose coupling: Modules don't directly depend on each other
  • Scalability: Easy to add new listeners
  • Audit trail: Complete history of what happened
  • Integration: Easy cross-module communication

Creating Domain Events

Event Structure

php
<?php

declare(strict_types=1);

namespace App\Modules\Operations\Domain\Events;

use App\Shared\Domain\Events\DomainEvent;

final class ProductCreated extends DomainEvent
{
    public function __construct(
        public readonly int $productId,
        public readonly string $name,
        public readonly string $sku,
        public readonly string $status,
        public readonly \DateTimeImmutable $occurredAt,
    ) {}

    public static function fromProduct(Product $product): self
    {
        return new self(
            productId: $product->id,
            name: $product->name,
            sku: $product->sku,
            status: $product->status->value,
            occurredAt: new \DateTimeImmutable(),
        );
    }

    public function toArray(): array
    {
        return [
            'product_id' => $this->productId,
            'name' => $this->name,
            'sku' => $this->sku,
            'status' => $this->status,
            'occurred_at' => $this->occurredAt->format('Y-m-d H:i:s'),
        ];
    }
}

Common Operations Events

Product Events:

php
ProductCreated::class          // New product added
ProductUpdated::class          // Product details changed
ProductActivated::class        // Product activated
ProductDeactivated::class      // Product deactivated
ProductDiscontinued::class     // Product discontinued

Inventory Events:

php
InventoryAdjusted::class       // Stock level changed
StockReserved::class           // Stock reserved for order
StockReleased::class           // Reservation released
LowStockDetected::class        // Below reorder point
OutOfStockDetected::class      // Inventory depleted

Purchase Order Events:

php
PurchaseOrderCreated::class    // New PO created
PurchaseOrderApproved::class   // PO approved
PurchaseOrderSent::class       // PO sent to supplier
PurchaseOrderReceived::class   // Goods received
PurchaseOrderCancelled::class  // PO cancelled

Recording Events in Aggregates

Event Storage in Entities

php
<?php

namespace App\Shared\Domain\Models;

abstract class BaseEntity extends Model
{
    private array $recordedEvents = [];

    protected function recordEvent(DomainEvent $event): void
    {
        $this->recordedEvents[] = $event;
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

Recording in Domain Methods

php
<?php

namespace App\Modules\Operations\Domain\Models;

final class Product extends BaseEntity
{
    public static function create(array $data): self
    {
        $product = new self($data);

        // Record domain event
        $product->recordEvent(ProductCreated::fromProduct($product));

        return $product;
    }

    public function activate(): void
    {
        if ($this->status === ProductStatus::ACTIVE) {
            throw new \DomainException('Product is already active');
        }

        $this->status = ProductStatus::ACTIVE;

        // Record domain event
        $this->recordEvent(ProductActivated::fromProduct($this));
    }
}

Event Dispatch

Automatic Dispatch by Repository

php
<?php

namespace App\Shared\Infrastructure\Repositories;

abstract class EloquentRepository
{
    public function save(BaseEntity $entity): void
    {
        $entity->save();

        // Dispatch all recorded events
        foreach ($entity->releaseEvents() as $event) {
            Event::dispatch($event);
        }
    }
}

Workflow:

  1. Domain method records event
  2. Repository saves entity
  3. Repository dispatches all recorded events
  4. Listeners handle events

Example Flow

php
// In controller
$command = new ActivateProductCommand(productId: 42);
$product = $this->commandBus->dispatch($command);

// In handler
public function handle(Command $command): Product
{
    $product = $this->productRepository->findOrFail($command->productId);

    // Domain method records ProductActivated event
    $product->activate();

    // Repository saves and dispatches events
    $this->productRepository->save($product);

    return $product;
}

// Events automatically dispatched to listeners

Event Listeners

Creating Listeners

php
<?php

declare(strict_types=1);

namespace App\Modules\Operations\Application\Listeners;

use App\Modules\Operations\Domain\Events\ProductActivated;
use Illuminate\Contracts\Queue\ShouldQueue;

final class PublishProductToCatalog implements ShouldQueue
{
    public function handle(ProductActivated $event): void
    {
        // Update search index
        SearchIndexService::updateProduct($event->productId);

        // Publish to catalog
        CatalogService::publishProduct($event->productId);

        // Notify sales team
        Notification::send(
            SalesTeam::all(),
            new ProductActivatedNotification($event)
        );
    }
}

Registering Listeners

In App\Providers\AppServiceProvider:

php
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    // Register event listeners
    Event::listen(
        ProductActivated::class,
        PublishProductToCatalog::class
    );

    Event::listen(
        InventoryAdjusted::class,
        UpdateInventoryCache::class
    );
}

Cross-Module Communication

Example: CRM → Sales Integration

When opportunity is won, create draft sales order:

php
// CRM Module Event
namespace App\Modules\CRM\Domain\Events;

final class OpportunityWon extends DomainEvent
{
    public function __construct(
        public readonly int $opportunityId,
        public readonly int $customerId,
        public readonly float $amount,
        public readonly array $products,
    ) {}
}

// Sales Module Listener
namespace App\Modules\Sales\Application\Listeners;

final class CreateOrderFromOpportunityWon implements ShouldQueue
{
    public function handle(OpportunityWon $event): void
    {
        // Create draft sales order
        $command = new CreateSalesOrderCommand(
            customerId: $event->customerId,
            items: $event->products,
            status: 'draft',
        );

        $this->commandBus->dispatch($command);
    }
}

Registration:

php
Event::listen(
    OpportunityWon::class,
    CreateOrderFromOpportunityWon::class
);

Benefits:

  • CRM module doesn't know about Sales module
  • Sales module subscribes to CRM events
  • Easy to add more listeners
  • Loose coupling maintained

Event Subscribers

php
<?php

namespace App\Modules\Operations\Application\Subscribers;

use Illuminate\Events\Dispatcher;

final class ProductEventSubscriber
{
    public function handleProductCreated(ProductCreated $event): void
    {
        // Handle product created
    }

    public function handleProductActivated(ProductActivated $event): void
    {
        // Handle product activated
    }

    public function subscribe(Dispatcher $events): void
    {
        $events->listen(
            ProductCreated::class,
            [ProductEventSubscriber::class, 'handleProductCreated']
        );

        $events->listen(
            ProductActivated::class,
            [ProductEventSubscriber::class, 'handleProductActivated']
        );
    }
}

Async Event Processing

Queued Listeners

php
final class PublishProductToCatalog implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 120;

    public function handle(ProductActivated $event): void
    {
        // Process async
    }
}

Benefits:

  • Non-blocking event handling
  • Retry on failure
  • Scalable processing
  • Better user experience

Testing Events

Assert Events Dispatched

php
use Illuminate\Support\Facades\Event;

public function test_product_created_event_dispatched(): void
{
    Event::fake();

    $command = new CreateProductCommand(dto: $dto);
    $this->commandBus->dispatch($command);

    Event::assertDispatched(ProductCreated::class, function ($event) {
        return $event->name === 'Laptop - Dell XPS 13';
    });
}

Assert Listeners Called

php
public function test_catalog_published_when_product_activated(): void
{
    Event::fake();

    $command = new ActivateProductCommand(productId: 42);
    $this->commandBus->dispatch($command);

    Event::assertDispatched(ProductActivated::class);
    Event::assertListening(
        ProductActivated::class,
        PublishProductToCatalog::class
    );
}

Event Sourcing (Future)

Event Store Pattern

While not currently implemented, events provide foundation for event sourcing:

php
// Future: Event-sourced aggregate
final class Product
{
    private array $events = [];

    public function apply(ProductCreated $event): void
    {
        $this->id = $event->productId;
        $this->name = $event->name;
        $this->sku = $event->sku;
    }

    public function replay(array $events): void
    {
        foreach ($events as $event) {
            $this->apply($event);
        }
    }
}

Best Practices

DO:

  • ✅ Name events in past tense (ProductCreated)
  • ✅ Make events immutable
  • ✅ Keep events focused (single responsibility)
  • ✅ Use queued listeners for async work
  • ✅ Record events in domain methods
  • ✅ Use for cross-module communication

DON'T:

  • ❌ Put business logic in events (use listeners)
  • ❌ Create circular event dependencies
  • ❌ Use events for synchronous coupling
  • ❌ Record events in controllers/services
  • ❌ Modify event data after creation

Examples in Operations Module

app/Modules/Operations/Domain/Events/
├── ProductCreated.php
├── ProductActivated.php
├── InventoryAdjusted.php
├── StockReserved.php
├── PurchaseOrderCreated.php
└── PurchaseOrderReceived.php

app/Modules/Operations/Application/Listeners/
├── UpdateSearchIndexOnProductChange.php
├── NotifyLowStockAlert.php
└── UpdateInventoryCacheOnAdjustment.php

Documentation for SynthesQ CRM/ERP Platform