Installation
composer require cvek/domain-events
Register the bundle in config/bundles.php (Symfony) or manually bootstrap in Laravel via service provider.
Entity Integration
Add RaiseEventsTrait to your domain entity:
use Cvek\DomainEvents\RaiseEventsTrait;
class User implements \Cvek\DomainEvents\RaiseEventsInterface
{
use RaiseEventsTrait;
}
Define a Domain Event
Extend AbstractSyncDomainEvent or AbstractAsyncDomainEvent:
class UserRegisteredEvent extends AbstractSyncDomainEvent
{
public function __construct(private User $user) {}
public function getUser(): User { return $this->user; }
}
Raise an Event Trigger in business logic:
$user = new User();
$this->raise(new UserRegisteredEvent($user));
Listen to Events Implement a listener (Symfony-style):
class UserRegisteredListener
{
public function __invoke(UserRegisteredEvent $event): void
{
// Handle event (e.g., send email)
}
}
Register in services.yaml (Symfony) or manually bind in Laravel’s DI container.
UserRegisteredEvent (async for non-blocking email):
class UserRegisteredEvent extends AbstractAsyncDomainEvent
{
public function __construct(private User $user) {}
}
UserRepository or service:
$user->register(); // Internally raises $this->raise(new UserRegisteredEvent($user));
// app/Listeners/UserRegisteredListener.php
public function handle(UserRegisteredEvent $event)
{
Mail::to($event->getUser()->email)->send(new WelcomeEmail());
}
| Type | When Triggered | Use Case | Async Support? |
|---|---|---|---|
preFlush |
Before DB persistence | Validate business rules pre-save | ❌ No |
onFlush |
During DB persistence | Optimistic locking, audit logs | ✅ Yes |
postFlush |
After DB persistence | Notifications, analytics, external APIs | ✅ Yes |
Example Workflow:
// 1. Pre-save validation
$this->raise(new ValidateUserEvent($user));
// 2. During save (async: send SMS)
$this->raise(new SendSmsVerificationEvent($user));
// 3. Post-save (async: log to analytics)
$this->raise(new TrackUserCreatedEvent($user));
Laravel Integration:
db):
// config/domain_events.php
'transports' => [
'async' => [
'dsn' => 'doctrine://default',
],
],
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('domain-events:consume')->everyMinute();
}
php artisan domain-events:consume
Best Practices:
preFlush/onFlush sync for critical logic (e.g., invariants).| Strategy | When to Use | Example |
|---|---|---|
| Explicit | One-off events in business logic | $user->promote() raises event |
| Aggregate Root | Domain-driven design (DDD) | UserAggregate dispatches events |
| Repository Hooks | CRUD operations | UserRepository::save() |
| Command Handlers | CQRS pattern | RegisterUserCommand |
Example: Aggregate Root Pattern
class UserAggregate
{
use RaiseEventsTrait;
public function promoteToAdmin()
{
$this->role = 'admin';
$this->raise(new UserRoleChangedEvent($this, 'admin'));
}
}
Unit Test Example:
public function test_user_registration_emits_event()
{
$user = new User();
$event = new UserRegisteredEvent($user);
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Email not valid');
$user->register(); // Should raise $event
// Assert event was raised (use a mock listener)
}
Integration Test (Async):
public function test_async_event_is_processed()
{
$event = new UserRegisteredEvent(new User());
$this->app->make(DomainEventDispatcher::class)->dispatch($event);
// Assert queue job was dispatched (Laravel)
$this->assertDatabaseHas('domain_events', [
'event_class' => UserRegisteredEvent::class,
]);
}
Async Event Ordering
event_id or timestamps for critical workflows.priority field to events or use Laravel’s queue priorities.Circular Dependencies
preFlush for validation, not postFlush.Doctrine Event System Limits
onFlush/postFlush events cannot modify the same entity they’re processing.preFlush for entity changes or async events.Async Transport Configuration
db transport requires Doctrine. For Laravel, ensure:
// config/domain_events.php
'transports' => [
'async' => [
'dsn' => 'doctrine://default', // Laravel: 'doctrine://entity_manager'
],
],
Event Serialization
__serialize() magic method.Log Unhandled Events Add a global listener:
$dispatcher->addListener('*', function ($event) {
\Log::debug('Unhandled event', ['event' => get_class($event)]);
});
Check Async Queue Laravel:
php artisan queue:work --once --queue=domain_events
Symfony:
php bin/console domain-events:consume
Event Dispatcher Inspection Dump all listeners:
$dispatcher = $this->app->make(DomainEventDispatcher::class);
print_r($dispatcher->getListeners());
Custom Transports
Implement TransportInterface for non-DB async (e.g., Redis, RabbitMQ):
class RedisTransport implements TransportInterface
{
public function publish(DomainEvent $event): void
{
Redis::publish('domain_events', serialize($event));
}
}
Event Retry Logic
Extend AbstractAsyncDomainEvent to add retry metadata:
class RetryableEvent extends AbstractAsyncDomainEvent
{
public int $retries = 0;
public ?\DateTimeInterface $retryAt = null;
}
Event Versioning
Add a version field to events for backward compatibility:
class UserRegisteredEvent extends AbstractAsyncDomainEvent
{
public const VERSION = '1.0';
public string $version;
}
Laravel Service Provider
Bind the dispatcher and listeners in register():
$this->app->bind(DomainEventDispatcher::class, function ($app) {
$dispatcher = new DomainEventDispatcher();
$dispatcher->addListener(UserRegisteredEvent::class, $app->make(UserRegisteredListener::class));
return $dispatcher;
});
Entity Manager Binding
Ensure the EntityManager is bound to the doctrine key:
// config/doctrine.php
'entity_managers' => [
'default' => [
'connection' => 'default',
'mappings' => [...],
],
],
How can I help you explore Laravel packages today?