In PSR-14, how does a StoppableEventInterface event stop further listeners, and what must a dispatcher do when a listener throws?
answer
- isPropagationStopped(): bool
- checked before each listener
- already stopped: zero listeners run
- a throw blocks the remaining listeners
- catch to log, then rethrow the original
basics
~20 sA PSR-14 dispatcher checks isPropagationStopped() before each listener and returns the event once it is true. A listener's throwable stops the remaining listeners and must reach the emitter; the dispatcher may only log and rethrow it.
solid answer
~40 sAn event opts into stopping by implementing `Psr\EventDispatcher\StoppableEventInterface`, whose single method is `isPropagationStopped(): bool`. The event itself decides when it is complete, typically when a listener supplies the answer, such as a `setResponse()` that flips an internal flag. For such events the dispatcher MUST call `isPropagationStopped()` before each listener, and as soon as it returns `true` it MUST return the event to the emitter without calling any further listeners; an event that starts out stopped reaches zero listeners. Errors are handled more strictly still: an exception or `Error` thrown by a listener MUST block all further listeners and MUST propagate back to the emitter. A dispatcher MAY catch it to log it or take extra action, but MUST then rethrow the original throwable, so swallowing listener failures is non-conforming.
code
php · 26 lines<?php
declare(strict_types=1);
use Psr\EventDispatcher\StoppableEventInterface;
final class ReminderDue implements StoppableEventInterface
{
private ?\DateTimeImmutable $deferredUntil = null;
public function __construct(public readonly Reminder $reminder) {}
public function deferUntil(\DateTimeImmutable $when): void
{
$this->deferredUntil = $when; // the answer makes the event complete
}
public function deferredUntil(): ?\DateTimeImmutable
{
return $this->deferredUntil;
}
public function isPropagationStopped(): bool
{
return $this->deferredUntil !== null;
}
}go deeper
Remember that a stoppable event can end dispatching early, and that an exception from a listener stops the remaining listeners and reaches the caller.
Explain the check before each listener, the zero-listener case, and the catch-log-rethrow rule for listener throwables.
Order listeners deliberately around stoppable events, keep must-run listeners safe, and handle listener failures in the emitter rather than expecting isolation.
Judge when synchronous, fail-fast in-process events suit a workflow and when a queue with independent retries per handler is the better contract.
## Two ways dispatching ends early A **PSR-14** dispatcher normally calls every listener the provider returns. Two things cut that short: a **stoppable event** that reports it has been handled, and a listener that **throws**. The rules for each are precise. ## Stoppable events An event becomes stoppable by implementing `Psr\EventDispatcher\StoppableEventInterface`: ```php interface StoppableEventInterface { public function isPropagationStopped(): bool; } ``` The specification leaves the meaning of "complete" to the event class. Its own example is an event that asks listeners to match a request to a response: a listener calls `setResponse()`, and from then on `isPropagationStopped()` returns `true`. There is no standard `stopPropagation()` method; the event designs its own way to become stopped. ## What the dispatcher must do with a stoppable event 1. Call `isPropagationStopped()` **before each listener** is called. 2. If it returns `true`, return the event to the emitter **immediately**. 3. Call **no further** listeners. A consequence the specification spells out: an event whose `isPropagationStopped()` is already `true` when dispatched reaches **zero** listeners. (The docblock on the interface phrases the duty as checking after each listener is called; the specification text, which also covers the first listener, requires the check before each one.) Because order decides who gets the chance to stop the event, the provider's ordering becomes significant for stoppable events. ## Listener errors The error-handling rules apply to every event: | Rule | Consequence | |---|---| | An exception or `Error` thrown by a listener **must** block further listeners | later listeners do not run for that dispatch | | It **must** propagate back to the emitter | the `dispatch()` call throws in the calling code | | The dispatcher **may** catch it to log or take other action | useful for adding context | | It **must** then rethrow the **original** throwable | wrapping or swallowing it is not conforming | So a PSR-14 dispatcher is not a fault-isolation layer. If one listener's failure must not affect the others, that listener has to handle its own errors, or enqueue its work so a separate process does it. ## The scheduler example A reminder scheduler dispatches `ReminderDue`. Suppose the listeners are, in order: a quiet-hours listener, a mail listener and a metrics listener. - If `ReminderDue` is stoppable and the quiet-hours listener marks it as deferred, making `isPropagationStopped()` return `true`, the mail and metrics listeners are skipped and the scheduler sees the deferral on the returned event. - If the mail listener throws because the mail server is down, the metrics listener does not run and `dispatch()` throws in the scheduler. The scheduler decides whether to retry the reminder. This makes two design choices visible: put the listener that may stop the event early in the order, and do not expect later listeners to run after a failure. ## Design guidance - Make an event stoppable only when "handled" has a clear meaning, typically a question with one answer. - Implement stopping as a consequence of the answer, such as `setResponse()`, rather than exposing a public flag any listener can flip. - Keep listeners that must always run, such as auditing, out of stoppable events, or order them first. - Wrap risky work inside the listener in its own `try`/`catch` if other listeners must still run. - In the emitter, catch listener failures where you can act on them; the dispatcher will not hide them. ## Interview traps - Believing the dispatcher checks the flag only after a listener runs, which would still call the first listener for an already-stopped event. - Believing dispatchers catch listener exceptions and continue with the next listener. - Assuming only `Exception` is covered: the rule names exceptions and errors alike.
- May a PSR-14 dispatcher wrap a listener's exception in its own DispatchFailed exception?No. The dispatcher may catch a listener's throwable to log it or take additional action, but it MUST then rethrow the original throwable. Wrapping replaces it and swallowing hides it; both break the rule that the listener's error propagates back to the emitter.
- How many listeners run if an event's isPropagationStopped() already returns true when dispatched?None. The dispatcher must check `isPropagationStopped()` before each listener, including the first, so an event that is already stopped goes straight back to the emitter. The specification states this consequence explicitly.
saying these in an interview costs you the question
- Believes the dispatcher catches listener exceptions and continues with the next listener.
- Thinks the stop flag is only checked after each listener has run.
- Expects StoppableEventInterface to define a stopPropagation() method.
- Wraps listener exceptions in a dispatcher-specific exception instead of rethrowing.
- Assumes only Exception, not Error, blocks the remaining listeners.