Files
summercms/docs/services/events.md
Jakub Zych efb35a2d35 feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
2026-09-30 22:59:25 +02:00

99 lines
4.9 KiB
Markdown

---
title: Events
description: Listen for and fire typed events on the application bus with festival, with priorities, collected results and a halting fire that stops when handled.
section: services
order: 20
---
# Events
`Event::listen` and `Event::fire` are how WinterCMS plugins extend each other. SummerCMS keeps the pattern with [festival](../../modules/festival/README.md): each application has one bus, `backpack.App.Events`, and plugins listen from their `Boot` step for events that other plugins fire. [Extending plugins](../plugins/extending.md) shows where events fit among the other extension points; this page covers the bus itself.
## Typed events
An event is a Go type, not a string. A listener is a function that takes a context and the event, and the bus routes by type, so a listener never receives a payload of the wrong shape and a mismatch does not compile. Name events after what happened, and keep them in the package of the plugin that fires them so listeners can import the type.
`festival.Bus.Listen` registers a listener at priority 0 and `festival.Bus.ListenPriority` at a given priority. Higher priorities run first, and listeners with the same priority run in registration order. The first argument is the ID of the plugin that owns the listener:
```go src=modules/festival/example_test.go#ExampleBus_Fire
bus := festival.New() // in a plugin, use app.Events
// acme.search and acme.notify extend acme.blog from their Boot steps.
bus.Listen("acme.search", func(ctx context.Context, e PostPublished) error {
fmt.Println("index", e.Title)
return nil
})
bus.ListenPriority("acme.notify", 10, func(ctx context.Context, e PostPublished) error {
fmt.Println("notify subscribers of", e.Title)
return nil
})
// acme.blog fires the event; higher priorities run first.
if err := bus.Fire(context.Background(), PostPublished{Title: "Hello"}); err != nil {
fmt.Println(err)
}
// Output:
// notify subscribers of Hello
// index Hello
```
`festival.Bus.Fire` runs every listener, even after one fails, and returns the failures joined with `errors.Join`. A listener that panics is recovered and reported as an error that names its plugin, so one faulty plugin cannot stop the others.
All three dispatch methods run the listeners on the caller's goroutine, before they return. For work that should not delay the request, a listener dispatches a job; see [Queued jobs](jobs.md).
## Collecting contributions
WinterCMS events often gather something from their listeners, such as extra form fields or menu items. `festival.Bus.Collect` runs every listener and, after each one, merges the map the event returns from `festival.Collectable.Collected`. A later listener wins when two set the same key. Use a pointer event so listeners can write to it:
```go src=modules/festival/example_test.go#ExampleBus_Collect
bus := festival.New()
bus.Listen("acme.seo", func(ctx context.Context, e *PostFormExtended) error {
e.fields = map[string]any{"meta_title": "text"}
return nil
})
bus.Listen("acme.gallery", func(ctx context.Context, e *PostFormExtended) error {
e.fields = map[string]any{"cover": "fileupload"}
return nil
})
fields, err := bus.Collect(context.Background(), &PostFormExtended{})
fmt.Println(fields, err)
// Output: map[cover:fileupload meta_title:text] <nil>
```
`festival.Bus.Collect` returns the payload gathered so far together with the joined errors, so one failing listener does not lose the others' contributions.
## Stopping at the first handler
The WinterCMS halting fire stops at the first listener that returns a result. `festival.Bus.UntilHandled` stops as soon as the event's `festival.Handleable.IsHandled` reports true, or at the first error, and returns whether the event was handled:
```go src=modules/festival/example_test.go#ExampleBus_UntilHandled
bus := festival.New()
bus.ListenPriority("acme.pages", 10, func(ctx context.Context, e *SlugResolving) error {
if e.Slug == "about" {
e.Found = "page"
}
return nil
})
bus.Listen("acme.blog", func(ctx context.Context, e *SlugResolving) error {
fmt.Println("acme.blog asked for", e.Slug)
e.Found = "post"
return nil
})
for _, slug := range []string{"about", "hello-world"} {
e := &SlugResolving{Slug: slug}
handled, err := bus.UntilHandled(context.Background(), e)
fmt.Println(slug, handled, e.Found, err)
}
// Output:
// about true page <nil>
// acme.blog asked for hello-world
// hello-world true post <nil>
```
Here `acme.pages` listens at a higher priority, so it gets the first chance to claim a slug, and `acme.blog` is asked only when no page matched.
## Events and transactions
Listeners run where the event is fired, inside any transaction the caller has open. A listener that writes to the database joins that transaction when it uses the transaction handle the event carries. A listener with a side effect outside the database, such as a mail or a broadcast, should defer it with `lagoon.AfterCommit`, so it does not announce a write that rolls back. See [Transactions](../database/transactions.md).