07. Events and listeners
The extensibility spine. A third-party package extends an application by registering listeners in a provider, which means it never has to ask you to edit a controller.
Defining
// app/Events/PostPublished.ts
export class PostPublished extends Event {
constructor(public readonly post: Post) { super() }
}
// app/Listeners/NotifySubscribers.ts
export const NotifySubscribers = defineListener(PostPublished, {
delivery: 'queued', // 'sync' | 'after' | 'queued'
queue: 'notifications',
priority: 10, // lower runs first
tries: 3,
backoff: [10, 60, 300],
async handle(event, ctx) {
await Mail.to(...).send(new PostPublishedNotice(event.post))
},
})
// app/Providers/EventServiceProvider.ts
Event.listen(PostPublished, [NotifySubscribers, IndexPostForSearch, WarmPostCache])
Event.subscribe(AuditSubscriber) // one class, many events
Event.listen('post.*', WildcardLogger) // wildcard by name
Delivery modes
| Mode | Runs | Retries | Survives function death |
|---|---|---|---|
sync | inline, before the action returns | no | n/a |
after | post-response, same invocation | no | no |
queued | in a worker | yes | yes |
sync is the default because it is the only mode where a listener can affect the outcome.
after is zero-infrastructure and best-effort. Good for analytics and cache warming, wrong for anything a user would notice missing.
queued is durable and the right default for anything touching a third party.
Dispatching
await Event.dispatch(new PostPublished(post))
// sync listeners run in priority order and are awaited, then queued listeners enqueue
Event.dispatch(new PostPublished(post), { afterCommit: true })
// held until the enclosing transaction commits, dropped on rollback
Event.dispatchAfterResponse(new PostPublished(post))
// every listener runs post-response regardless of its declared mode
await Event.until(new PostPublishing(post))
// halts on the first non-null return and yields it
afterCommit is not a nicety. Without it, a queued listener can start before the row it reads is visible, and the resulting bug reproduces roughly one time in fifty.
Propagation
Sync listeners run ordered by priority. A listener halts the chain by returning false or calling event.stopPropagation().
A halted chain does not enqueue the queued listeners behind it. That is what makes a veto mean something rather than being a suggestion that fires after the side effects have already been scheduled.
event.isPropagationStopped() is readable by the dispatcher, so a controller can branch on the outcome.
Model events
Model lifecycle hooks dispatch on the same bus, which makes Observers ordinary listeners:
creating created updating updated saving saved deleting deleted restored
A creating listener returning false aborts the write. Because it is the same bus, a package can observe your models without you registering anything model-specific.
Serialization
Queued listeners run in a worker with no request, no cookies, and no actor.
Events serialize model references as { __model: 'Post', id } and the worker rehydrates them through a model registry. Two consequences that belong in the docs rather than in a stack trace:
1. A queued listener sees the record as it exists when it runs, not as it was when dispatched. 2. A worker runs unwarded with no actor. Auth.user() is null. Anything authorization-sensitive must receive an explicit actor on the event.
Failure
| Delivery | A listener throws |
|---|---|
sync | bubbles into the request, the action fails |
after | logged, request already returned, no retry |
queued | retried per tries and backoff, then written to the failed table with its payload, replayable via reeve errand:retry |
Discoverability
reeve event:list prints every event, its listeners, their delivery mode, and their priority, including listeners registered by third-party packages.
An event-driven codebase is only legible if you can answer "what happens when this fires" without grepping. In a framework where packages register listeners, that command is not optional.
In development, every dispatch logs the event, the listeners that ran, their modes, and the halt point if any.