Services
Events
Listen for and fire typed events on the application bus with festival, with priorities, collected results and a halting fire that stops when handled.
On this page
Event::listen and Event::fire are how WinterCMS plugins extend each other. SummerCMS keeps the pattern with festival: each application has one bus, backpack.App.Events, and plugins listen from their Boot step for events that other plugins fire. Extending plugins 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:
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.
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:
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:
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.