yiisoft/event-dispatcher
PSR-14 event dispatcher for Yii applications with a simple listener API, listener providers, dependency injection support, and extensible configuration. Lightweight and framework-agnostic, it helps decouple components by dispatching events to typed listeners.
yiisoft/event-dispatcher is a small, PSR-14-compliant dispatcher from the Yii3 ecosystem. It is not a Laravel replacement — it deliberately does the one thing PSR-14 defines (dispatch an object to interested listeners) and nothing else. Treat it as infrastructure you'd adopt when a library or a non-Laravel codebase forces a PSR-14 contract, or when you're writing a framework-agnostic package and want zero coupling.
Install:
composer require yiisoft/event-dispatcher
Read three files first, in this order:
Yiisoft\EventDispatcher\Dispatcher\Dispatcher — implements Psr\EventDispatcher\EventDispatcherInterface; the whole dispatch() contract lives here (~40 lines).Yiisoft\EventDispatcher\Provider\Provider — implements Psr\EventDispatcher\ListenerProviderInterface; this is where listeners are registered and where most of the package's actual logic sits.Yiisoft\EventDispatcher\Provider\Attribute\ListenTo — the attribute used to declare which events a listener handles.Minimal first use case — a domain event raised in a package that must not depend on Illuminate\*:
use Psr\EventDispatcher\EventDispatcherInterface;
use Yiisoft\EventDispatcher\Dispatcher\Dispatcher;
use Yiisoft\EventDispatcher\Provider\Provider;
$provider = new Provider();
$dispatcher = new Dispatcher($provider);
$provider->attach(
static function (UserRegistered $event): void {
// send welcome mail, etc.
},
UserRegistered::class, // explicit event class — do this
);
$dispatcher->dispatch(new UserRegistered($user));
That's the whole mental model: Provider = listener registry, Dispatcher = fan-out loop. Everything else (attributes, ListenerCollection, CompositeDispatcher) is sugar over those two objects.
If you are in a Laravel app and landed here because a Composer dependency type-hints EventDispatcherInterface, do not rip out Laravel's dispatcher — write a three-line bridge instead (see below).
Manual registration (most explicit, most portable). Pass the event class name(s) as variadic strings. Avoid relying on parameter-type inference; it works for typed closures but breaks the moment a listener is a callable array or an invokable object with a union/object hint.
Attribute-driven registration. Provider::attach() inspects the listener via reflection for #[ListenTo(...)] when no event classes are given. This is the idiomatic Yii3 style — one attribute per event, repeatable:
use Yiisoft\EventDispatcher\Provider\Attribute\ListenTo;
final class SendWelcomeEmail
{
#[ListenTo(UserRegistered::class)]
#[ListenTo(UserInvited::class)]
public function __invoke(object $event): void { /* ... */ }
}
$provider->attach(new SendWelcomeEmail());
ListenerCollection for config-driven wiring. Build the collection once (typically in a DI config file), attach it to the provider during bootstrap. This is the seam you want in a Yii3 app, because listeners then live in config rather than in bootstrap.php, and the collection is cheap to serialize/cache compared to reflection-scanning attributes on every request.
CompositeDispatcher when two dispatchers coexist. If you're migrating toward PSR-14 while Laravel's dispatcher still handles the bulk of app events, the composite lets a single injected EventDispatcherInterface fan out to both. Useful as a transitional shim; delete it once migration finishes.
Bridging into Laravel (the realistic integration). Bind PSR-14 to the Laravel dispatcher so third-party packages get a working implementation without a second event system:
// AppServiceProvider::register()
$this->app->singleton(\Psr\EventDispatcher\EventDispatcherInterface::class, fn ($app) =>
new class($app['events']) implements \Psr\EventDispatcher\EventDispatcherInterface {
public function __construct(private \Illuminate\Contracts\Events\Dispatcher $events) {}
public function dispatch(object $event): object
{
return $this->events->dispatch($event); // Laravel returns the event payload
}
}
);
Note the direction: this lets PSR-14 consumers call into Laravel. The reverse (this package dispatching Laravel events) is rarely what you want, because Laravel's listeners are registered by string name and this package dispatches objects — the class name becomes the event name, which mostly works but loses Laravel's wildcard and Event::listen('*') features.
Stoppable events. Implement Psr\EventDispatcher\StoppableEventInterface on the event and the dispatcher stops iterating once isPropagationStopped() returns true. Pattern: a ValidationFailed event that a high-priority listener can veto. Since there is no priority system, "high priority" means "registered first" — see gotchas.
Testing. Don't mock the dispatcher. Register a recording listener in the test's provider and assert on what arrived:
$recorded = [];
$provider->attach(function (object $e) use (&$recorded) { $recorded[] = $e; }, UserRegistered::class);
Or inject a trivial EventDispatcherInterface fake that stores events in a public array — the interface has one method, so it's five lines.
No priorities, no wildcards, no queue, no subscribers. Listeners fire in registration order and that's it. Yii3 relies on config file ordering for priority; if you need real ordering, wrap listeners in a sorting pass at bootstrap or accept the coupling. If you're coming from Laravel, note that ShouldQueue, Event::listen('*'), model events, broadcasting, and Event::fake() all do not exist here.
PSR-14 version drift is the sharpest edge. The package's last tagged release is 2022-10-27, which predates/nearly coincides with psr/event-dispatcher 2.0. PSR-14 1.0 declares dispatch(object $event) with no return type; 2.0 declares dispatch(object $event): object. If Composer resolves psr/event-dispatcher: ^2.0 and the installed dispatcher still implements the 1.0 signature, you get a fatal signature error. Pin psr/event-dispatcher and verify composer show psr/event-dispatcher before debugging anything else. This single issue is the main reason the package's maturity score is low — it has effectively been idle since 2022 and its maintenance story depends on whether upstream accepts PRs.
Reflection cost is per-attach(), not per-dispatch(). Attribute scanning happens at registration. If you attach hundreds of invokable listeners on every request, you're paying reflection cost on every request — cache the built Provider or the ListenerCollection in the container, and only rebuild on deploy.
Explicit event classes beat type inference. Provider::attach($listener) without event classes inspects the listener's first parameter. It silently registers nothing useful (or throws) for untyped closures, callable strings, or object-typed parameters. Always pass the event class explicitly; it also makes grep possible when hunting for "who listens to this event?"
Container resolution of class-strings depends on version. Newer revisions allow listener class-strings resolved through a container passed into the provider/collection. Do not assume the package will instantiate SendWelcomeEmail::class for you — check the constructor signature of Provider/ListenerCollection in the version you installed and instantiate listeners yourself if the container isn't wired.
Exceptions abort the fan-out. No listener isolation: listener #2 throwing means listeners #3..n never run and the exception surfaces from dispatch(). If a listener is best-effort (metrics, audit log), catch inside the listener — do not expect the dispatcher to swallow.
Debugging. Wrap the dispatcher in a decorator that logs $event::class and listener counts; there is no built-in profiler or debug collector in this package. A 20-line decorator implementing EventDispatcherInterface and delegating to the inner dispatcher gives you a full event trace with zero runtime cost in prod (bind the decorator only in local/testing).
Extension points are narrow by design. You can (a) decorate EventDispatcherInterface, (b) decorate ListenerProviderInterface, or (c) use CompositeDispatcher. There is no hook system, no middleware, no listener pipeline. That's a feature for a PSR-14 package and a limitation if you wanted a framework.
How can I help you explore Laravel packages today?