swag-industries/doctrine-domain-events
Adds Doctrine domain event support to Laravel: entities can record domain events, and the package collects and dispatches them through Laravel’s event system after Doctrine flushes or transactions commit, keeping persistence and side effects cleanly separated.
swag-industries/doctrine-domain-events is a Doctrine ORM add-on, not an Eloquent one. It only earns its place in a Laravel app once you already run Doctrine for at least part of your domain (usually via laravel-doctrine/orm). If everything is Eloquent, stop here — $this->dispatch() on models and model observers already cover this ground.
Assuming Doctrine is in play:
composer require swag-industries/doctrine-domain-events
The package requires doctrine/orm and PHP 8.x, and has no Laravel service provider of its own. You wire it yourself.
Read these first (in order):
src/RecordsEvents.php — the trait your aggregate roots use (recordThat(), releaseEvents()).src/ContainsRecordedEvents.php — the contract the Doctrine subscriber looks for.src/Subscriber/ — this is the piece that scans the UnitOfWork at flush time and moves recorded events into a collector.Smallest working example. Model an aggregate root:
namespace App\Domain\Order;
use SwagIndustries\DoctrineDomainEvents\ContainsRecordedEvents;
use SwagIndustries\DoctrineDomainEvents\RecordsEvents;
final class Order implements ContainsRecordedEvents
{
use RecordsEvents;
public function __construct(private string $id) {}
public function pay(): void
{
// ... mutate state ...
$this->recordThat(new OrderPaid($this->id));
}
}
Then, once the entity is persisted/updated and flushed:
$order = new Order('ord_123');
$order->pay();
$em->persist($order);
$em->flush(); // subscriber collects OrderPaid
// events now live in the collector, not on the entity
Your first real use case: record an event inside a domain method, persist the entity, and let the flush boundary decide when the event becomes visible. That separation — record at mutation time, release at commit time — is the whole point of the library, and it's what Eloquent models give you for free but Doctrine entities do not.
Record in the aggregate, release after flush. Never dispatch from inside an entity method. The entity records; something outside the entity releases. This keeps entities pure and testable without a container.
$order->pay();
$em->flush();
foreach ($collector->releaseEvents() as $event) {
$dispatcher->dispatch($event);
}
Bridge the collector to Laravel's dispatcher. The core package has no opinion about how events are delivered. A small adapter is all you need:
namespace App\Infrastructure;
use Illuminate\Contracts\Events\Dispatcher;
use SwagIndustries\DoctrineDomainEvents\Collector;
final class LaravelEventPublisher
{
public function __construct(
private Collector $collector,
private Dispatcher $events,
) {}
public function publish(): void
{
foreach ($this->collector->releaseEvents() as $event) {
$this->events->dispatch($event);
}
}
}
Call publish() from a Doctrine postFlush listener, from a middleware terminate(), or — better — from a DB::afterCommit() hook so nothing fires on a rolled-back transaction.
One request/job = one collector lifecycle. Bind the collector as a singleton per request in a service provider, and reset it on JobProcessed / Octane request completion. Otherwise a queue worker accumulates events across jobs and you dispatch stale ones.
Map domain events to Laravel listeners. Domain events are plain PHP objects with public readonly properties, so EventServiceProvider::$listen works directly:
protected $listen = [
\App\Domain\Order\OrderPaid::class => [
\App\Listeners\SendOrderReceipt::class,
\App\Listeners\ReserveInventory::class,
],
];
Use ShouldQueue on listeners rather than queuing the domain event itself — the event stays a value object, the queueing concern stays in Laravel.
Transactional outbox when it matters. If losing an event is unacceptable, don't dispatch straight after flush. Insert the serialized event into an outbox table inside the same Doctrine transaction, then let a scheduled command drain it. The collector's releaseEvents() becomes "move to outbox" rather than "dispatch now." This is the pattern the library naturally supports, and the one most teams migrate to once payments are involved.
Testing. In unit tests, skip the EntityManager entirely: instantiate the entity, call the method, assert on $entity->releaseEvents(). The trait is deliberately usable without Doctrine, which is the main reason to prefer it over hand-rolling a $events array on every entity.
Collection happens at flush, not at recordThat(). If the subscriber scans getScheduledEntityInsertions() / getScheduledEntityUpdates(), an entity whose only change is a recorded event — for example, you mutated a private array that Doctrine's change tracking doesn't notice — may never be collected. Verify how the shipped subscriber picks entities before you rely on it; if it only reads the scheduled-changes lists, you may need to call $em->getUnitOfWork()->scheduleForUpdate($entity) or record a changed timestamp alongside the event.
releaseEvents() is destructive. Once released, the events are gone. If your dispatcher throws mid-loop, half your events are dispatched and half are lost. Wrap the release in a try/finally that re-buffers, or use the outbox pattern.
Doctrine's own events shadow this. postFlush, onFlush, and postPersist are Doctrine lifecycle hooks; do not confuse them with Laravel's Illuminate\Database\Events\* or with your domain events. Similarly, Illuminate\Foundation\Events\Dispatchable on a domain event will pull in Laravel traits you probably don't want in your domain layer.
Unmanaged entities are invisible. new Order(...) that you never persist() records events that no subscriber will ever see. Same for entities that were detach()ed or came back from a different EntityManager. If events "disappear," check $em->contains($entity) first.
Nested flushes are a trap. Firing $em->flush() from inside a domain event listener (say, an audit listener) re-enters the subscriber while the collector is mid-release. Keep listeners read-only, or guard re-entry with a boolean flag in the collector adapter.
Long-running workers leak. Under Horizon, Octane, or a queue:work daemon, entity-level event arrays grow for the life of the process unless released. Always release after flush, and clear the EntityManager ($em->clear()) at job boundaries.
Serialization for queued listeners. Domain events often hold entity references or closures. Make events out of scalars/VOs only (OrderPaid(string $orderId, int $amountCents)), or you'll get Serialization of 'Closure' is not allowed the moment a listener is queued.
Extension points are thin. With 4 stars and zero dependents, expect to read the source rather than the docs. The trait and the contract are stable and small; the subscriber and collector are the parts you'll subclass or replace. Fork-friendly, but don't assume the Symfony bundle's wiring semantics carry over to a plain Laravel app.
How can I help you explore Laravel packages today?