AvelonDocs

Guide 07

Events and listeners

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

ModeRunsRetriesSurvives function death
syncinline, before the action returnsnon/a
afterpost-response, same invocationnono
queuedin a workeryesyes

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

DeliveryA listener throws
syncbubbles into the request, the action fails
afterlogged, request already returned, no retry
queuedretried 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.