vanio/doctrine-domain-events
A Doctrine ORM integration that lets your entities record and dispatch domain events. Collect events during persistence, then release them after flush via a configurable dispatcher, keeping your domain model decoupled from infrastructure.
1. Install it (it is not a Laravel package)
composer require vanio/doctrine-domain-events
This is a Doctrine ORM extension, not a Laravel package. There is no service provider, no config file, and no auto-discovery. You are almost certainly using it alongside laravel-doctrine/orm (or your own Doctrine integration), and you have to wire it into Doctrine yourself. That single fact drives most of the day-to-day experience: expect Laravel-flavoured ergonomics to be missing.
2. Look at two things first
DomainEventEmitter trait in the package source — this is what your entities will use. Confirm the exact method name in your pinned version (emit(), raise(), recordDomainEvent(), etc.); the README is the only documentation and it is short.Vanio\DoctrineDomainEvents — this is what has to be registered on each EntityManager's EventManager. Its postFlush listener is where the "flush inside flush" behaviour lives.3. Register the subscriber on the EntityManager
// app/Providers/DoctrineDomainEventsServiceProvider.php
public function boot(): void
{
$em = $this->app['em']; // laravel-doctrine default manager
$em->getEventManager()->addEventSubscriber(
$this->app->make(\Vanio\DoctrineDomainEvents\DomainEventSubscriber::class)
);
}
(Use whatever the dispatcher/subscriber class is actually called in the version you pin. If your Doctrine setup has multiple entity managers, do this per manager — the subscriber holds state tied to one EntityManager.)
4. Emit from an aggregate root
use Doctrine\ORM\Mapping as ORM;
use Vanio\DoctrineDomainEvents\DomainEventEmitter;
#[ORM\Entity]
class Order
{
use DomainEventEmitter;
public function place(): void
{
// ... mutate state, guard invariants
$this->emit(new OrderPlaced($this->id));
}
}
5. Flush
$order->place();
$em->flush(); // the package dispatches OrderPlaced after the flush completes
First use case to try: order placement. Order::place() emits OrderPlaced, a listener queues a confirmation email / pushes to a webhook. That is the 80% case, and it is the one that validates your wiring end to end.
Aggregate root records, dispatcher delivers. Keep entities free of services and Laravel facades. The entity records plain value objects; the package collects them and dispatches after the unit of work has been written. Your listeners live in the application layer and can be normal Laravel listeners or jobs.
Bridge package events into Laravel's dispatcher. Don't let the domain leak into event() calls inside entities. A single adapter keeps the boundary clean:
final class DispatchDomainEventsToLaravel
{
public function __invoke(object $domainEvent): void
{
event($domainEvent); // Laravel dispatcher, listeners, queued jobs
}
}
Register this as the package's listener/dispatcher callback (whatever the package exposes in your version). Now Event::listen(OrderPlaced::class, ...) and ShouldQueue listeners work as usual.
Events are recorded, then dispatched — use that for testing. Because the entity holds pending events before flush, you can assert intent without touching the database:
$order = new Order(...);
$order->place();
// Whatever the trait's accessor is (getDomainEvents(), releaseDomainEvents(), ...)
$this->assertCount(1, $order->getDomainEvents());
After flush(), assert that your Laravel fake received the event with Event::fake([OrderPlaced::class]). Two different assertions: recording (domain) and dispatch (application).
Nested flush for derived records. This is the package's differentiator. Inside a domain event listener you can persist something else and call flush() again — an audit row, a projection, a derived entity — and the package will keep draining the event queue until it is empty. Keep this usage narrow: one listener, one derived record, no chains. It is not a workflow engine.
Event::listen(OrderPlaced::class, function (OrderPlaced $event) use ($em) {
$em->persist(new OrderAuditLog($event->orderId, 'placed'));
$em->flush(); // supported: this runs inside the outer flush's dispatch phase
});
Wrap the write in a transaction, and let events be post-commit-ish. With Doctrine, flush() is transactional already; if you use $em->transactional(fn () => ...), events dispatch after the flush inside that transaction. For side effects that must not run on rollback, prefer queued listeners and let the queue's afterCommit / after_commit behaviour handle it — or explicitly dispatch after the outer transaction.
Multiple entity managers / multiple connections. Register the subscriber per EM. Do not use a singleton dispatcher shared across managers; the nested-flush state and pending-event buffers belong to one unit of work.
Testing setup. In-memory SQLite with doctrine/dbal and a schema tool works fine for this package — it has no database-specific dependencies. Boot the EM in your test case, flush, and assert. Reset the dispatcher between tests if it is a singleton holding state.
Outbox when it matters. If losing an event on a crashed process is unacceptable (payments, external notifications), this package is not an outbox. Record a domain_events table row in the same transaction and drain it with a queue worker. Use this package for in-process orchestration, the outbox for durability.
Adoption is tiny — pin and isolate. Six stars, zero known dependents, one maintainer org. Pin an exact version in composer.json, read the source (it is small), and wrap the trait and dispatcher behind your own RecordsDomainEvents interface so you can swap in a Symfony-style EventDispatcher or your own postFlush subscriber later without touching entities.
Events fire after the flush, not before. A listener cannot mutate the aggregate and expect it to be persisted by the same flush(). Either mutate before flush (normal domain code) or, if you genuinely need to, rely on the nested-flush support and accept the extra round trip.
The pending-event buffer is in memory. If the process dies between flush() and dispatch, the events are gone. There is no persistence. This is the main reason to keep listeners fast and idempotent.
Clear the buffer on rollback/close. If a flush throws, the EntityManager may close and the recorded events can linger on the entity. Check whether your version clears on onClear; if not, call the trait's release/clear method in error handling, or you will re-dispatch stale events on the next successful flush.
Nested flush recursion is on you. A listener that persists an entity which itself emits an event will re-enter the dispatch loop. The package supports the mechanism; it does not detect cycles. Add a guard (a flag on the event, or a depth counter in your listener) for anything that can emit while handling.
Doctrine's EventManager is per-EntityManager, and Laravel's global event dispatcher is not. A common mistake is registering the package's subscriber on Event::subscribe(...). It must go on $em->getEventManager(). Conversely, don't dispatch domain events through the EM's event manager — that is the low-level lifecycle channel.
postFlush runs inside the flush() call stack. An exception in a listener leaves the EM in a fragile state and the caller sees it as a flush failure. Catch and log inside listeners; push anything that can fail to the queue.
Order of subscribers matters. If you have other postFlush subscribers (audit logging, search indexing), they may run before or after the package's dispatch. Doctrine runs subscribers in registration order — register yours deliberately, and don't rely on another subscriber having run.
Detached entities in queued listeners. Serialising an entity into a job is a trap. Emit events carrying scalar IDs and re-load the aggregate in the listener. This also makes the event serialisable for Laravel's queue and for your test assertions.
Debugging tip: log at both ends. Log in the trait (or a thin wrapper around emit()) with the aggregate class and event class, and log in the Laravel adapter before calling event(). When something "doesn't fire", the question is always which of the three failed: not recorded, not dispatched, or not listened to. Those two log lines separate them instantly.
Extension points worth knowing: the trait (recording), the subscriber/dispatcher (delivery), and the listener callback (bridging to Laravel). Those are the only three seams. Do not subclass package internals — they are small and unversioned enough that you want your coupling in one adapter class.
How can I help you explore Laravel packages today?