symfony/event-dispatcher
The EventDispatcher component lets application components communicate by dispatching events and listening to them. Register listeners on named events, control priority, and decouple your PHP code with a simple, standalone Symfony library.
Install and pick a version
composer require symfony/event-dispatcher:^7.4 # LTS, PHP 8.1+
# or ^8.1 for PHP 8.4+ (strict return types enforced)
In a Laravel app this package is not a replacement for Illuminate\Events\Dispatcher — Laravel ships its own dispatcher and Event:: facade. You add Symfony's when you need a PSR-14 dispatcher (Psr\EventDispatcher\EventDispatcherInterface) to satisfy a third-party library, or you want a framework-agnostic domain event bus you can lift out of Laravel later. Keep the two worlds separate and bridge explicitly (see below).
Read these three files first (≈15 minutes total)
vendor/symfony/event-dispatcher/EventDispatcher.php — ~200 lines; the whole component.vendor/symfony/event-dispatcher/EventSubscriberInterface.phpvendor/symfony/event-dispatcher-contracts/EventDispatcherInterface.php — what you should type-hint against.First use case: a domain event inside a service
// app/Domain/Order/OrderPlaced.php
final readonly class OrderPlaced
{
public function __construct(
public string $orderId,
public int $totalCents,
) {}
}
// app/Providers/EventBusServiceProvider.php
use Symfony\Component\EventDispatcher\EventDispatcher;
use Psr\EventDispatcher\EventDispatcherInterface;
public function register(): void
{
$this->app->singleton(EventDispatcher::class, fn () => new EventDispatcher());
$this->app->alias(EventDispatcher::class, EventDispatcherInterface::class);
}
// anywhere
public function store(EventDispatcherInterface $events)
{
$order = Order::create(...);
$events->dispatch(new OrderPlaced($order->id, $order->total_cents));
}
At this point nothing listens — that's correct. Add listeners only as the number of side effects grows (email, ledger entry, webhook). If you only ever have one side effect, use a plain service call and skip the bus.
Closure/__invoke listeners registered at boot
$dispatcher->addListener(OrderPlaced::class, function (OrderPlaced $e) {
SendOrderReceipt::dispatch($e->orderId);
}, priority: 0);
Register by FQCN, not by a string alias. Class names survive refactors and IDE renames; string aliases silently stop firing when you move a namespace.
Subscribers for grouping
final class OrderSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
OrderPlaced::class => [['onPlaced', 10], ['audit', -10]],
OrderRefunded::class => 'onRefunded',
];
}
public function __construct(private Ledger $ledger, private Audit $audit) {}
public function onPlaced(OrderPlaced $e): void { $this->ledger->post($e); }
public function audit(OrderPlaced $e): void { $this->audit->write('order.placed', $e); }
public function onRefunded(OrderRefunded $e): void { /* ... */ }
}
Register the subscriber class as a Laravel singleton and wire it once in a service provider:
$this->app->singleton(OrderSubscriber::class);
$this->app->afterResolving(EventDispatcher::class, function (EventDispatcher $d) {
$d->addSubscriber($this->app->make(OrderSubscriber::class));
});
Priorities are how you sequence cross-cutting concerns
Higher integer runs first; default is 0. Typical layout: 100 = validation/guard (may stop propagation), 50 = state mutation, 0 = notification, -100 = logging/metrics. Don't rely on registration order for same-priority listeners — service provider order is not something you want in your critical path.
Stoppable events for a real veto
final class CheckoutStarted implements StoppableEventInterface
{
private bool $stopped = false;
public function isPropagationStopped(): bool { return $this->stopped; }
public function stop(): void { $this->stopped = true; }
}
A fraud-check listener at priority 100 calls stop(); the downstream "charge card" listener never runs. dispatch() still returns the event object, so the caller can inspect it.
Bridging to Laravel's dispatcher (the pattern you'll actually need)
Don't try to make one dispatcher impersonate the other. Instead, make one direction explicit:
// Symfony-side listener that hands off to Laravel's world
$dispatcher->addListener(OrderPlaced::class, function (OrderPlaced $e) {
event(new \App\Events\OrderWasPlaced($e->orderId)); // Laravel events, queues, broadcast
}, -50);
This gives you PSR-14 for library-facing code and Laravel's ShouldQueue, broadcasting, and Event::fake() for everything else. Reverse the arrow (Laravel listener → Symfony dispatch) only if you're migrating.
Testing without booting Laravel
The dispatcher is a plain object, which makes domain tests fast:
$dispatcher = new EventDispatcher();
$seen = [];
$dispatcher->addListener(OrderPlaced::class, function ($e) use (&$seen) { $seen[] = $e->orderId; });
(new Checkout($dispatcher))->handle($cart);
$this->assertSame(['ord_1'], $seen);
For integration tests inside Laravel, Event::fake() will not intercept Symfony dispatches — assert against your own spy listener or the real downstream effect.
Optional attribute-based registration
#[AsEventListener] lives in FrameworkBundle, not in this package — you don't get it in Laravel. If you want that ergonomics, write ~20 lines in a service provider that reflects over app_path('Listeners') and calls addListener() from the attribute arguments. Worth it only once you have more than a handful of listeners.
Event::listen() does not see Symfony dispatches and vice versa. This is the #1 source of "my listener isn't firing." Grep for both dispatch( and event( before debugging anything else.dispatch(object $event, ?string $eventName = null). Laravel: dispatch($event, $payload = [], $halt = false). Passing a string as the first argument worked pre-5.x and now throws TypeError. Always pass an object.SendOrderReceipt listener kills the remaining listeners and bubbles to the caller. For anything touching the network or a third party, make the listener a ShouldQueue Laravel job, or wrap in try/catch + report().$this (a controller, a request) are a memory leak under Octane and long-running queue workers. Register singletons in a service provider, not per-request. The TraceableEventDispatcher had exactly this leak fixed in 6.4.36 / 7.4.8 / 8.0.8 — stay on a patched patch release.TraceableEventDispatcher is dev-only. Wrap it with symfony/stopwatch, enable it behind app()->isLocal(), and call reset() between requests if you run Octane (also fixed for reset-during-dispatch in 6.4.37 / 7.4.9 — again, patch level matters).GenericEvent is legacy. It's a stringly-typed bag of arguments with no IDE support. Define one small readonly class per event; it costs 8 lines and pays for itself the first time you rename a property.removeListener() with closures. You need the identical callable instance; store the closure in a property if you must remove it, or use a subscriber object and removeSubscriber().new a fresh EventDispatcher on every resolution because it has no constructor dependencies — meaning listeners registered on one instance are invisible to another. $this->app->singleton(...) is not optional.Psr\EventDispatcher\EventDispatcherInterface keeps your domain code swappable and lets you drop in a Laravel-backed adapter later without touching call sites.EventDispatcher or implementing these interfaces breaks on upgrade. For a Laravel app, ^7.4 (LTS) is the low-risk pick.booted() hook.How can I help you explore Laravel packages today?