headsnet/domain-events-bundle
Symfony bundle for domain events: define and dispatch events from your domain, register subscribers, and defer delivery until after Doctrine transactions commit, keeping business logic decoupled from infrastructure.
The package is a Symfony bundle: it ships domain-event primitives (an event recorder on aggregate roots, a dispatcher, and tag/attribute-based listener registration). Two realistic entry points from a Laravel codebase:
headsnet/laravel-domain-events) instead of the bundle. The concepts (aggregate root, event recorder, listener registration) are identical, and the v0.4.5 transaction behaviour described below applies to the same underlying model.composer require headsnet/domain-events-bundle, register the bundle/provider, and confirm the dispatcher is resolvable from the container before writing a single listener.Where to look first, in this order:
AggregateRoot / event-recorder trait — this is where record(new SomethingHappened(...)) lives.First use case — and make it a two-part experiment, because it settles the single most important question about v0.4.5:
// 1) outside a transaction
$order = Order::place($command);
$this->orders->save($order);
$this->events->release($order); // observed: fires immediately?
// 2) inside a transaction
DB::transaction(function () use ($order) {
$this->events->release($order); // observed: fires now, at commit, or never?
});
Observe both. If the listener fires only after commit, the guard defers. If it never fires, the guard suppresses and you must move the release outside the transaction. Do not assume which one you have — the release note ("prevent event publishing during active transactions") is compatible with either implementation, and your entire integration strategy depends on the answer.
Record on the aggregate, release at the boundary. Entities only record events; the application layer releases them after persistence. This keeps entities container-free and makes the transaction window explicit rather than implicit.
Prefer dispatching after the transaction commits. Given v0.4.5, the safest default shape is:
$order = DB::transaction(function () use ($command) {
$order = Order::place($command); // records OrderPlaced
$this->orders->save($order);
return $order;
});
$this->events->release($order); // outside the transaction: deterministic
This works identically whether the guard defers or suppresses, and it removes any dependency on how the package implements the guard. Reach for release-inside-the-transaction only once you've confirmed the semantics with the experiment above.
If you do release inside a transaction, design for "later". Treat every listener as running after the unit of work has closed. It must not read rows written later in the same transaction, and it must not depend on the request's in-memory state.
Keep listeners idempotent and side-effect-only. Whatever the guard does, delivery can still be retried or duplicated (queue workers, commit retries). Key each listener's work on a stable event ID so replays are safe.
Fan out, don't build a god-listener. Register several small listeners per event (notification, projection, integration sync) so one failure doesn't take down the others.
Bridge to Laravel's dispatcher by decoration. Wrap the package dispatcher in a thin adapter that calls Event::dispatch(). Existing Event::listen() and ShouldQueue listeners keep working, while the aggregates themselves stay framework-agnostic. The decorator is also the right seam for logging and metrics.
Testing pattern. Unit-test the aggregate by asserting releaseEvents() returns the expected objects. Integration-test the transaction behaviour with two tests: one asserting the listener does not run synchronously inside an open transaction, and one asserting it does run after commit (or is intentionally dropped) — pin the actual behaviour so a future patch bump can't silently change it.
v0.4.5 is a behavioural change shipped in a patch release. The changelog line is one sentence; the impact is not. Any listener that previously fired mid-transaction — writing to a second connection, pushing to a search index, reading the just-written row through a different PDO handle — must be re-audited. Some of those will now see missing data, and some will not run at all.
Rollbacks and suppression. If the guard defers, a rolled-back transaction publishes nothing. If it suppresses, nothing was ever queued. Either way, "audit every attempt including failures" needs a separate, non-transactional path — do not try to get it from domain events.
Nested transactions and savepoints. Releasing in an inner transaction while an outer one is open means the event waits for (or is blocked by) the outermost scope. Keep nesting shallow in application code; DB::transaction() in a listener is an easy way to accidentally nest.
Long-running transactions. A batch job holding one transaction open across thousands of records is the worst case: either events pile up in memory and burst at commit, or they're silently dropped. Chunk the work into per-chunk transactions and release per chunk.
Ordering is no longer dispatch-ordered. Once publishing is coupled to transaction boundaries, two events recorded in sequence across different transactions can publish in commit order. Never encode ordering assumptions in a listener; carry an explicit sequence or version field on the event if you need it.
Debugging "the listener silently doesn't fire". First suspect a transaction. Check DB::transactionLevel() > 0 at release time, and temporarily log inside the package's publish/guard hook to see which events were actually handed to the dispatcher. Only after you've ruled out the transaction should you go looking at listener registration/discovery.
Extension points. The dispatcher is decoratable — that's the cleanest place for logging, metrics, and the Laravel Event bridge. If the transaction awareness sits behind a transaction-manager abstraction, that's the seam for non-default connections. Don't patch the vendor code to change guard behaviour; decorate.
Version pinning. Pin ^0.4.5 and read the changelog before every bump. The 0.x line has shipped behavioural changes in patch releases, and this one is a case in point.
How can I help you explore Laravel packages today?