gpslab/domain-event
A lightweight, framework-agnostic PHP domain event library. It provides an event bus, listener and subscriber registration, event-to-listener mapping, and deferred event dispatch to help decouple domain logic.
This is a small, focused DDD building-block library — not a framework integration. It gives you four primitives: a domain EventBus interface, AggregateRoot/Entity base classes that collect domain events, listener/subscriber abstractions (callable and container-backed), and a couple of bus implementations (SimpleEventBus, ContainerEventBus, QueueEventBus). The whole value proposition is that your aggregates stay framework-free while a service layer drains their events into a bus.
Install and verify constraints first. The package is composer-installable but the last release is from August 2019:
composer require gpslab/domain-event
Before writing code, open vendor/gpslab/domain-event/composer.json and check the php constraint. As a 2019-era package it targets PHP 7.x; it will usually install on modern PHP because the code is simple, but there are no PHP 8 attributes and nothing here is PSR-14 (Psr\EventDispatcher) compliant. Confirm it actually installs against your runtime rather than assuming.
Where to look first. Read, in this order:
src/EventBus/EventBus.php — the single-method interface everything hinges on (dispatch($event)).src/Aggregate/AggregateRoot.php and src/Aggregate/Entity.php — raiseDomainEvent($event) + releaseEvents().src/EventBus/SimpleEventBus.php — the reference bus, plus its addListener/addSubscriber API.src/Listener/CallableEventListener.php and src/Listener/ContainerListener.php — how listeners get resolved.First use case — the canonical lifecycle. Raise an event inside an aggregate, drain it in a service, dispatch it on the bus:
use GpsLab\Component\Domain\Event\Aggregate\AggregateRoot;
final class Order extends AggregateRoot
{
private bool $paid = false;
public function __construct(private string $id) {}
public function pay(): void
{
if ($this->paid) {
throw new \DomainException('Already paid');
}
$this->paid = true;
$this->raiseDomainEvent(new OrderPaid($this->id));
}
}
use GpsLab\Component\Domain\Event\EventBus\SimpleEventBus;
use GpsLab\Component\Domain\Event\Listener\CallableEventListener;
$bus = new SimpleEventBus();
$bus->addListener(OrderPaid::class, new CallableEventListener(
fn (OrderPaid $e) => $logger->info("Order {$e->id()} paid")
));
$order = new Order('ord-1');
$order->pay();
foreach ($order->releaseEvents() as $event) {
$bus->dispatch($event);
}
That raise → release → dispatch loop is the entire day-to-day mental model. Everything else is wiring that loop into your application.
Unit-of-work dispatcher. Do not dispatch from inside the aggregate or the handler that mutates it. Put one service between "command" and "transaction commit" that releases and dispatches, so listeners only ever run after state is consistent:
final class DomainEventDispatcher
{
public function __construct(private EventBus $bus) {}
/** @param AggregateRoot[] $aggregates */
public function flush(array $aggregates): void
{
foreach ($aggregates as $aggregate) {
foreach ($aggregate->releaseEvents() as $event) {
$this->bus->dispatch($event);
}
}
}
}
This also makes it trivial to swap in a QueueEventBus later without touching aggregates or listeners.
Subscribers for grouping. Instead of dozens of addListener calls, implement EventSubscriber in one class per module and register it once. This keeps the wiring file readable and mirrors how the package intends you to compose listeners:
final class BillingSubscriber implements EventSubscriber
{
public function __construct(private Mailer $mailer) {}
public function subscribe(EventBus $bus): void
{
$bus->addListener(OrderPaid::class, new CallableEventListener(
fn (OrderPaid $e) => $this->mailer->sendReceipt($e->id())
));
}
}
Container-backed listeners for expensive services. When a listener's dependencies are heavy (mailers, HTTP clients), use the container wiring instead of instantiating services eagerly. ContainerEventBus takes a PSR-11 container plus listener service IDs and resolves them only when the matching event fires. In Laravel, Illuminate\Container\Container already implements Psr\Container\ContainerInterface, so you can pass the app container directly:
use GpsLab\Component\Domain\Event\EventBus\ContainerEventBus;
$this->app->singleton(EventBus::class, function ($app) {
$bus = new ContainerEventBus($app);
$bus->addListener(OrderPaid::class, SendOrderReceipt::class);
return $bus;
});
Then register listener classes as normal Laravel bindings, and the bus resolves them lazily.
Deferred dispatch with the queue bus. QueueEventBus wraps another bus and holds events until you flush it. This is the package's answer to "don't dispatch until the transaction commits" — collect during the request, then release explicitly:
$queuedBus = new QueueEventBus($app->make(EventBus::class));
foreach ($order->releaseEvents() as $event) {
$queuedBus->dispatch($event); // queued, not delivered
}
$db->commit();
$queuedBus->release(); // delivered now
Reach for this when a listener must not see a rolled-back row, and for anything resembling a transactional outbox.
Testing with a tracing bus. Any listener failure is normally hard to observe. For unit tests, either bind a no-op EventBus and assert on releaseEvents() directly, or use a trace/recording bus so assertions become assertContains(OrderPaid::class, $recorded). Prefer asserting on the aggregate's released events — that's the contract you actually own.
It is a primitives library, not an integration. There is no service provider, no facade, no config, no auto-discovery. You write all wiring yourself. Budget the time for a DomainEventServiceProvider and treat it as the single place where listeners are declared.
Do not mix it with Laravel's event() helper. You now have two event systems, two listener registries, and two ways to fire things — an easy path to events that look dispatched but reach nobody. Pick one per bounded context. If you're already deep in Laravel events, the honest question is whether this package adds anything over dispatching plain objects through Laravel's dispatcher; it earns its keep only on the aggregate side, where raiseDomainEvent/releaseEvents enforces the deferral discipline.
releaseEvents() drains. Calling it twice returns an empty array the second time. That's correct behavior, but it means a dispatcher that throws mid-loop loses the events you already drained but haven't dispatched. Wrap the loop so a failure doesn't silently discard events, or copy the array before dispatching.
No event marker interface is enforced. raiseDomainEvent() accepts anything. Nothing stops someone raising a DTO, an Eloquent model, or a string. Add a DomainEvent interface of your own and static-analyse (PHPStan/Psalm) the aggregate base class to catch this at build time — the package's 2019 shape was written before strict typing became standard.
Aggregates and Eloquent don't compose cleanly. The base classes expect you to extend AggregateRoot, and PHP has single inheritance — you can't also extend Model. Either keep domain aggregates as plain PHP classes separate from Eloquent models (the DDD-correct answer, more work), or use composition: hold an event collector inside the model rather than inheriting. Don't fight this halfway; decide up front.
ContainerEventBus resolves services lazily, which shifts failures later. A missing or mis-bound listener service ID won't fail at boot — it fails when the event fires, often in production. Add a wiring smoke test that dispatches every declared event class once and asserts no NotFoundException.
Listener ordering is registration order and unspecified across subscribers. If your listeners have ordering requirements (e.g. persist before notify), don't rely on the bus — chain it explicitly in one listener, or accept that event-driven flows here are unordered.
Exceptions in listeners propagate to the dispatcher synchronously. A failing mailer rolls back your "dispatch after commit" boundary into a half-processed state. Decide per listener whether failures are fatal, and catch/log inside listeners where they aren't.
QueueEventBus needs an owner. It holds events in memory until released. Forget to release and the events vanish with the request. Release twice and you can double-deliver. Put construction and release in one narrow scope (middleware, unit-of-work, or a job wrapper), never in a shared singleton.
Long-running workers leak. With the queue bus or container bus held as singletons under Octane/queue workers, resolved listeners and any captured state persist between jobs. Flush the queue and reset bindings per job, or scope the bus to the request.
2019-era lifecycle risk. Low stars, stale releases, no active maintenance. The API surface is tiny enough that vendoring the handful of classes or migrating to PSR-14 with a thin adapter is realistic — plan for it. If you adopt, wrap the bus behind your own interface so a future swap doesn't ripple through listeners.
How can I help you explore Laravel packages today?