symfony/event-dispatcher-contracts
A small set of PHP interfaces that abstract Symfony's event dispatcher, letting you build on proven event semantics and swap implementations without locking your code to a specific package.
Read the package before installing anything. The whole contract is two files (~60 lines): EventDispatcherInterface.php (extends Psr\EventDispatcher\EventDispatcherInterface, adds an optional ?string $eventName argument) and Event.php (a base class whose only job is stopPropagation() / isPropagationStopped()). There is no dispatcher, no listener provider, no subscriber interface — those live in symfony/event-dispatcher. Understanding that boundary up front saves a lot of confusion.
Check whether you already have it. Laravel pulls symfony/event-dispatcher (and therefore these contracts) transitively, so a typical app needs no composer require at all:
composer show symfony/event-dispatcher-contracts
Only require it explicitly in a shared library package, where it belongs in require (not require-dev) alongside psr/event-dispatcher.
Bind the interface once. Laravel's container does not resolve Psr\EventDispatcher\EventDispatcherInterface or the Symfony variant, so type-hinting it without a binding throws Target [...] is not instantiable. Add a thin adapter in AppServiceProvider::register():
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as SymfonyDispatcher;
final class LaravelEventDispatcherAdapter implements SymfonyDispatcher
{
public function __construct(private \Illuminate\Contracts\Events\Dispatcher $events) {}
public function dispatch(object $event, ?string $eventName = null): object
{
$this->events->dispatch($event); // Laravel derives the name from the class
return $event;
}
}
// AppServiceProvider::register()
$this->app->bind(EventDispatcherInterface::class, fn ($app) => new LaravelEventDispatcherAdapter($app['events']));
$this->app->bind(SymfonyDispatcher::class, fn ($app) => $app->make(EventDispatcherInterface::class));
Note bind, not singleton — see the gotchas.
First use case: pick one service that currently calls the event() helper, inject EventDispatcherInterface instead, and leave every listener untouched. Existing Event::listen(UserRegistered::class, SendWelcomeEmail::class) registrations keep working because the adapter forwards to Laravel's dispatcher, and Event::fake() assertions still pass. That's the 80% win at near-zero risk.
Hybrid default: portable event definitions, Laravel dispatcher. Keep events as plain objects implementing nothing framework-specific (or extending the contract Event when you want stop-propagation), and let the app dispatch them. Laravel keys listeners off the class name, so Event::listen() and Event::assertDispatched() work unchanged.
// In a shared package — no Illuminate imports
final class UserRegistered
{
public function __construct(public readonly string $userId) {}
}
Library/service pattern: type-hint the narrowest interface you need. If you only dispatch, depend on Psr\EventDispatcher\EventDispatcherInterface — Symfony's contract extends it, so a PSR-14 type-hint accepts both Symfony's dispatcher and your adapter. Reach for the Symfony variant only when you need to pass an explicit event name. Never call the event() helper or use Illuminate\Foundation\Events\Dispatchable inside a package; Dispatchable::dispatch() is a Laravel global.
Event-name mapping. The ?string $eventName parameter is Symfony's way of letting one class be dispatched under several names. Laravel is class-name-centric: php artisan event:list, Event::fake() matching, and Event::assertDispatched() all use the class string. If the name differs from $event::class, dispatch as $this->events->dispatch($eventName, [$event]) so named listeners fire — but document that those listeners become invisible to event:list.
Listener registration stays Laravel-native. Continue using EventServiceProvider::$listen, Event::subscribe(), or shouldDiscoverEvents(). For package listeners outside app/Listeners, register them explicitly in the package's service provider (shouldDiscoverEvents only scans the configured paths) and remember php artisan event:cache bakes the list in — clear it after adding bindings in local dev.
Testing pattern. Because the adapter resolves events from the container at call time, Event::fake() transparently swaps in EventFake:
Event::fake([UserRegistered::class]);
$this->post('/register', [...]);
Event::assertDispatched(UserRegistered::class, fn ($e) => $e->userId === '42');
For package tests that must not depend on Laravel, inject a hand-rolled spy implementing Psr\EventDispatcher\EventDispatcherInterface that appends to an array — this is the whole reason the interface exists.
Keep framework concerns on the Laravel side. ShouldQueue, ShouldBroadcast, InteractsWithSockets, SerializesModels all assume framework internals. Have a Laravel listener on the domain event translate it into a broadcast/queued notification instead of decorating the shared event class.
Full PSR-14 bus, only when justified. If you genuinely need Symfony's listener priorities, EventSubscriberInterface, or a dispatcher shared with a Symfony service, run Symfony\Component\EventDispatcher\EventDispatcher as a second dispatcher alongside Laravel's, or wire a bridge such as spatie/laravel-psr-event-dispatcher. Treat this as a deliberate second channel, not a replacement — third-party Laravel packages assume Illuminate\Contracts\Events\Dispatcher.
bind, not singleton. Event::fake() calls $app->instance('events', $fake). A singleton adapter that captured the original dispatcher at construction will keep dispatching to the real one and your fakes will silently miss. Resolve $app['events'] lazily on every dispatch() call.dispatch() signatures are a trap. Laravel: dispatch($event, $payload = [], $halt = false). Symfony contract: dispatch(object $event, ?string $eventName = null). Type-hinting the contract and then passing a payload array compiles fine and misbehaves at runtime. The adapter is the only place that should know both.$halt = true); PSR-14 and Symfony return the event object. Never build logic on the return value of a dispatch you might later swap — the adapter above deliberately returns $event.false; Symfony halts on $event->stopPropagation(). Laravel's dispatcher does not reliably honour isPropagationStopped(). Decide explicitly in the adapter whether to check $stopped between listeners, and write a test for it.addListener, no priorities, no EventSubscriberInterface, no listener provider here. If a design doc says "use the contracts to register listeners," that's wrong — you need the concrete symfony/event-dispatcher for that.symfony/event-dispatcher-contracts:^2 into an app whose framework already resolved ^3 produces a composer conflict for no benefit. Let the framework decide; only constrain it inside a library you publish (^3.5 requires PHP 8.1).Event::fake() is Laravel-only. It replaces Laravel's dispatcher. If any code path dispatches through a standalone Symfony dispatcher instance, fakes and assertDispatched see nothing. Keep a single dispatch channel, or fake both.php artisan event:list shows only Laravel-registered listeners keyed by class name; Symfony-style named events won't appear. Use php artisan tinker → app(Psr\EventDispatcher\EventDispatcherInterface::class) to confirm the binding resolves to your adapter, and drop a logger() call in the adapter temporarily to see everything crossing the boundary.event() directly.How can I help you explore Laravel packages today?