Most developers I know use the EventDispatcher the same way. They create an event, wire up a listener, dispatch it somewhere, and move on. It works. Nobody complains.
The problem is that "works fine" is exactly what keeps you from going deeper. I spent years in that spot, using the EventDispatcher for simple notifications and never feeling the need to push further. What I missed were the patterns that actually change how you structure an application. That's what this is about.
The Basics (So We're On the Same Page)
You know this part. Skip ahead if you want.
class OrderPlaced
{
public function __construct(public readonly Order $order) {}
}
$this->dispatcher->dispatch(new OrderPlaced($order));
#[AsEventListener]
class SendOrderConfirmation
{
public function __invoke(OrderPlaced $event): void
{
// send the email
}
}
The #[AsEventListener] attribute (Symfony 6.0+) wires the listener automatically. On a class with __invoke, Symfony infers the event type from the type hint of the first parameter. Leave it untyped and it fails silently. That one will bite you eventually.
Stoppable Events
Without stoppable events, a "first responder wins" pattern looks like this:
class ResolvePaymentMethod
{
public function __invoke(ResolvePaymentMethodEvent $event): void
{
if ($event->getMethod() !== null) {
return; // someone else already resolved it
}
if ($this->supports($event->getOrder())) {
$event->setMethod('stripe');
}
}
}
Every listener manually checks whether a previous one already ran. It works, but the contract is implicit. You're trusting that every future developer reads every other listener before writing a new one.
Stoppable events make the contract explicit:
use Symfony\Contracts\EventDispatcher\Event;
class ResolvePaymentMethod extends Event
{
private ?string $method = null;
public function setMethod(string $method): void
{
$this->method = $method;
$this->stopPropagation();
}
public function getMethod(): ?string
{
return $this->method;
}
}
The moment a listener sets a method, propagation stops. No manual checks. No scattered conditionals.
One caveat I've learned the hard way: use stoppable events on events you fully own. If a third-party bundle registers a listener on the same event at a higher priority, it can stop propagation before yours runs. Silently. No error, no warning. Run debug:event-dispatcher before you assume your listener is executing.
Priorities: Good for Ordering, Unreliable for Correctness
Priorities let you control when listeners run. Higher numbers run first. In Symfony 6.0+ the attribute handles it cleanly:
#[AsEventListener(event: UserRegistered::class, priority: 100)]
class ValidateUserData { ... }
#[AsEventListener(event: UserRegistered::class, priority: 50)]
class CreateDefaultWorkspace { ... }
#[AsEventListener(event: UserRegistered::class, priority: 0)]
class SendWelcomeEmail { ... }
#[AsEventListener(event: UserRegistered::class, priority: -10)]
class LogRegistrationForAnalytics { ... }
On Symfony 5.x, set priorities via getSubscribedEvents(). The nested array format exists because a single event can have multiple listeners in the same subscriber, each with their own priority:
public static function getSubscribedEvents(): array
{
return [
// Single listener with priority
UserRegistered::class => [['onUserRegistered', 50]],
// Multiple listeners on the same event
OrderPlaced::class => [['firstHandler', 20], ['secondHandler', 10]],
];
}
Here's the thing I keep coming back to with priorities: they're fine for loose ordering. "Logging should happen after the main work." Reasonable. But the moment your application's correctness depends on strict execution order, you're on shaky ground. Any listener from a bundle, or from a colleague who skipped the architecture docs, can slip in at any priority without warning.
Use priorities to express when something runs. If you need to express whether something runs based on what came before, that's a job for explicit service calls, not priority numbers.
Event Subscribers: Co-locating Related Behavior
Subscribers let a single class listen to multiple events. The reason I reach for them is co-location: all the behavior for a given concern lives in one place.
class AuditLogSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
OrderPlaced::class => 'onOrderPlaced',
OrderCancelled::class => 'onOrderCancelled',
OrderRefunded::class => 'onOrderRefunded',
];
}
public function onOrderPlaced(OrderPlaced $event): void
{
$this->auditLog->record('order_placed', $event->order->getId());
}
// ...
}
Logging, auditing, metrics: these are the cases where subscribers earn their keep. The behavior is genuinely independent of the dispatching code. Grouping it by concern, all audit logic together regardless of which events trigger it, makes it easier to find and change.
That said, the discoverability problem is real. A developer debugging a missing audit entry still needs to know AuditLogSubscriber exists. The grouping helps once you know where to look. It doesn't help you find it from the dispatch site. That's a general property of event-driven code, and I think it's worth being honest about before you wire everything this way.
Extension Points: Before and After Hooks
If you're building something others will extend, events are one of the cleanest mechanisms I've found. Inheritance ties you to a class hierarchy. Callback arrays get unwieldy as the number of extension points grows. Events give you explicit contracts that downstream code can hook into without touching yours.
The pattern I use most often is a before/after pair:
class PreOrderProcessing extends Event
{
private bool $cancelled = false;
public function __construct(public readonly Order $order) {}
public function cancel(): void
{
$this->cancelled = true;
}
public function isCancelled(): bool
{
return $this->cancelled;
}
}
class PostOrderProcessing extends Event
{
public function __construct(public readonly Order $order) {}
}
class OrderProcessor
{
public function process(Order $order): void
{
if ($this->dispatcher->dispatch(new PreOrderProcessing($order))->isCancelled()) {
return;
}
// ... core processing logic
$this->dispatcher->dispatch(new PostOrderProcessing($order));
}
}
A listener on the after hook:
#[AsEventListener]
class NotifyWarehouseAfterOrder
{
public function __invoke(PostOrderProcessing $event): void
{
$this->warehouseNotifier->notify($event->order);
}
}
OrderProcessor dispatches two events and knows nothing else. Everything that needs to happen before or after plugs in without touching the core. This is how Symfony's HttpKernel works. kernel.request, kernel.response, kernel.exception: the routing and security system hangs off those hooks. The kernel knows nothing about any of it directly.
Where this breaks down is when listeners start needing to feed complex information back to the dispatching code. At that point the indirection is fighting you. A direct service call is clearer.
The Honest Problem: Event-Driven Code is Hard to Trace
I want to be direct about this because I've seen it glossed over in every EventDispatcher tutorial I've read.
When you dispatch an event, the execution path splits across however many listeners are registered. You can't follow it by reading the code at the dispatch site. You have to know the full listener graph, and in a large codebase that's not always obvious.
Symfony gives you tools:
# See all registered listeners
php bin/console debug:event-dispatcher
# Filter by event (single backslashes, works on Linux and macOS without quotes)
# On Windows, behaviour varies by shell; wrap in double quotes if needed
php bin/console debug:event-dispatcher App\Event\UserRegistered
The debug toolbar shows the same for web requests: what fired, in what order, whether propagation stopped. Use both regularly.
But tooling has limits. In a codebase with thirty listeners, debugging an unexpected side effect means tracing a graph, not reading a call stack. That cognitive overhead is real and it compounds as the codebase grows.
One thing that helps: if your listeners are doing work that doesn't need to complete synchronously, dispatch via Symfony Messenger. With the sync transport, messages are handled immediately in the same process. No infrastructure required, but also no retry logic or failure isolation. With an async queue transport, you get retries, failure handling, and full decoupling from the request lifecycle. Either way, you're separating what happened from what should happen next, which makes both sides easier to reason about. I'll come back to this in the domain events section.
When to Reach for It (and When Not To)
After nineteen years of Symfony projects, the framing that's helped me most is this: events are for concerns that are genuinely independent. Not just loosely coupled. Genuinely independent, meaning neither side needs to know the other exists.
What that looks like in practice: logging and auditing that doesn't affect the outcome of the dispatching code. Extension points in bundles where you want downstream hooks without modifying core logic. First-responder chains where the chain stops once one listener resolves a value, and you want that contract in the event class rather than scattered across guard clauses.
What it doesn't look like: listeners that need to share state or coordinate with each other. Dispatching code that needs reliable return values or structured error handling from what runs next. Flows where execution order is load-bearing for correctness. And situations where the code is simple and stable enough that a direct service call would just be easier to follow. Not everything needs to be decoupled. Indirection has a cost even when it's justified.
The test I apply: if I removed the EventDispatcher and replaced this with a direct service call, would the code be meaningfully harder to extend or maintain? If the answer is no, the event is probably not earning its place.
Domain Events: Modeling Intent, Not Just Plumbing
This is the pattern I'd push hardest if someone asked where to invest time beyond the basics.
Without domain events, side effects accumulate in services:
class PlaceOrderService
{
public function place(Order $order): void
{
$order->setStatus('placed');
$this->entityManager->flush();
$this->mailer->sendConfirmation($order);
$this->inventory->reserve($order);
// and so on
}
}
Every new consequence of placing an order becomes another dependency on this service. The list grows. The coupling grows. The service becomes the place where everything ends up because it has to be somewhere.
Domain events break that apart:
class Order
{
private array $domainEvents = [];
public function place(): void
{
// ... business logic
$this->domainEvents[] = new OrderPlaced($this);
}
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
}
Then in the application layer, after the flush:
$entityManager->persist($order);
$entityManager->flush();
foreach ($order->pullDomainEvents() as $event) {
$this->dispatcher->dispatch($event);
}
place() now expresses intent. It says: this happened. Not: here are all the things that should follow. Each consequence registers itself as a listener. The service stops being the place where everything accumulates.
The flush ordering is non-negotiable. Dispatch before flush and if the flush fails, you've fired side effects for a state change that never committed. I've seen this cause hard-to-reproduce bugs in production.
Even with correct ordering, you're exposed if dispatching throws mid-loop. Some events have fired, some haven't. Dispatching via Messenger helps: side effects become retryable messages rather than in-process calls, and a failure in one doesn't leave the rest in an unknown state.
For "exactly once" delivery guarantees, the outbox pattern is the real answer. Events are persisted transactionally alongside your domain data, then processed by a dedicated worker. It's genuinely more complex: a polling mechanism or change data capture tooling like Debezium, idempotency on the consumer side, at-least-once delivery semantics. Reach for it when you actually need those guarantees. Most applications don't.
As for when to use this pattern at all: if your entities have real business logic and placing an order means something beyond setting a status field, domain events will pay for themselves quickly. If you're working in a thin CRUD app where entities are essentially database row representations, the ceremony probably isn't worth it. The honest test is whether your domain objects have behavior worth expressing. If they do, domain events give you a clean way to express it.
What I've Landed On
The EventDispatcher is one of those tools where the basic usage is obvious and the advanced usage is invisible until you've needed it and didn't have it.
The question that matters is whether the behavior you're wiring is genuinely independent of the code dispatching the event. If it is, events are a natural fit and they'll keep your services small and your extension points clean. If it isn't, you're adding indirection to a problem that a direct service call would solve more clearly.
Used well, the EventDispatcher doesn't add complexity. It moves complexity to where it belongs.