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::classEvent 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 discontinuedInventory 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 depletedPurchase 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 cancelledRecording 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:
- Domain method records event
- Repository saves entity
- Repository dispatches all recorded events
- 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 listenersEvent 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
Grouping Related Listeners
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
Related Patterns
- CQRS Pattern - Commands emit events
- Aggregates - Event recording in aggregates
- Repository Pattern - Event dispatch on save
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