andy-thorne/domain-events-bundle
Install the package:
composer require andy-thorne/domain-events-bundle
Enable the bundle in config/bundles.php:
return [
// ...
AndyThorne\Components\DomainEventsBundle\DomainEventsBundle::class => ['all' => true],
];
Configure config/packages/domain_events.yaml (minimal async setup):
domain_events:
orm: true
transport: async_domain_events
framework:
messenger:
transports:
async_domain_events: '%env(ASYNC_MESSENGER_TRANSPORT_DSN)%'
buses:
domain_event.bus:
default_middleware: allow_no_handlers
Define a domain event (e.g., src/Event/UserRegistered.php):
namespace App\Event;
use AndyThorne\Components\DomainEventsBundle\Events\DomainEventInterface;
class UserRegistered implements DomainEventInterface
{
public function __construct(public string $userId) {}
}
Dispatch an event in a domain service (e.g., src/Service/UserService.php):
use AndyThorne\Components\DomainEventsBundle\DomainEvents;
class UserService {
public function registerUser(string $email): void {
$user = new User($email);
$this->entityManager->persist($user);
$this->entityManager->flush();
DomainEvents::dispatch(new UserRegistered($user->getId()));
}
}
Create a handler (e.g., src/MessageHandler/UserRegisteredHandler.php):
use AndyThorne\Components\DomainEventsBundle\Events\DomainEventInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class UserRegisteredHandler {
public function __invoke(UserRegistered $event): void {
// Side effects (e.g., send email, log, etc.)
}
}
Run the worker (if using async transport):
php bin/console messenger:consume async_domain_events -vv
Use this bundle to trigger actions (e.g., sending emails, updating caches) after an entity is persisted, without tightly coupling the domain logic to infrastructure concerns.
Domain Layer:
UserRegistered, OrderShipped).UserService or repository method).$user = $this->userRepository->save($user);
DomainEvents::dispatch(new UserRegistered($user->getId()));
Infrastructure Layer:
UserRegisteredHandler).#[AsMessageHandler]) for clarity.Testing:
DomainEvents dispatcher in unit tests.MessengerTestTrait (from Symfony Messenger) to test handlers in isolation.use Symfony\Component\Messenger\Test\MessageBusTestTrait;
class UserRegisteredHandlerTest {
use MessageBusTestTrait;
public function testHandler() {
$bus = $this->createBus([new UserRegisteredHandler()]);
$bus->dispatch(new UserRegistered('user-123'));
// Assert side effects (e.g., logged email)
}
}
Doctrine Lifecycle Events:
prePersist/preUpdate. Use postPersist/postUpdate or dispatch manually after flush().$entityManager->persist($entity);
$entityManager->flush();
DomainEvents::dispatch(new EntityUpdated($entity->getId()));
Event Sourcing:
domain_events) for replayability.Bulk Operations:
$entityManager->flush();
foreach ($entities as $entity) {
DomainEvents::dispatch(new EntityProcessed($entity->getId()));
}
Retry Logic:
config/packages/messenger.yaml:
framework:
messenger:
transports:
async_domain_events:
dsn: '%env(ASYNC_MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
Event Versioning:
Ulid for events to ensure chronological ordering.use Symfony\Component\Uid\Ulid;
class UserRegistered implements DomainEventInterface {
public function __construct(public string $eventId, public string $userId) {
$this->eventId = (new Ulid())->toRfc4122();
}
}
Event Dispatch Timing:
prePersist may not be processed if the entity fails validation.flush() or in a separate transaction.try {
$entityManager->persist($entity);
$entityManager->flush();
DomainEvents::dispatch(new EntityCreated($entity->getId()));
} catch (\Exception $e) {
$entityManager->rollback();
throw $e;
}
Async Transport Configuration:
default_middleware: allow_no_handlers for the domain event bus will cause synchronous failures.domain_event.bus is configured to allow no handlers:
framework:
messenger:
buses:
domain_event.bus:
default_middleware: allow_no_handlers
Circular Dependencies:
EventBus decorator to track in-flight events or implement idempotency checks.Doctrine Proxy Issues:
getId() or initialize lazy-loaded properties before dispatching:
$entity->getId(); // Force initialization
DomainEvents::dispatch(new EntityUpdated($entity->getId()));
Testing Async Handlers:
MessengerTestTrait or mock the DomainEvents dispatcher:
$dispatcher = $this->createMock(DomainEvents::class);
$dispatcher->expects($this->once())->method('dispatch');
Check Event Dispatch:
framework:
messenger:
transports:
async_domain_events:
dsn: '%env(ASYNC_MESSENGER_TRANSPORT_DSN)%'
options:
logger: true
Worker Logs:
php bin/console messenger:consume async_domain_events -vv
Event Routing:
php bin/console debug:container --parameter=framework.messenger.routing
Custom Event Bus:
framework:
messenger:
buses:
custom_domain_bus:
middleware:
- AndyThorne\Components\DomainEventsBundle\Middleware\LogDomainEvents
- allow_no_handlers
Then update domain_events.bus in config to custom_domain_bus.Dynamic Event Routing:
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
use Symfony\Component\Messenger\Transport\TransportInterface;
class TenantAwareRouter implements RouterInterface {
public function getRoutes(): iterable {
yield new Route(
new DomainEventInterface(),
new TransportInterface(...)
);
}
}
Register it asHow can I help you explore Laravel packages today?