Files
summercms/docs/plugins/extending.md
Jakub Zych ff62906301 feat(32-02): record events from every admin write path
- Delete and each bulk-delete row fire RecordDeleted after commit
- tree reorder fires RecordSaved per moved record (parent/sort in model types)
- relation-manager child create/update/delete fire events naming the
  controller that edits the child's model
- TestRecordEvents covers create, update, translated slug, no-op, rollback
  (validation, FormBeforeUpdate, extension Save), failing listener, reorder,
  child writes, delete and bulk delete; extension Save sees the changes
- docs: Record events in extending.md, admin-controllers notes, cabana README
2026-10-26 22:35:03 +01:00

7.8 KiB

title, description, section, order
title description section order
Extending plugins Extend other plugins through typed events, published services, optional dependencies and GORM callbacks, and replace a plugin by forking its module. plugins 30

Extending plugins

Plugins in WinterCMS extend each other by listening to events and by calling extend on another plugin's classes at runtime. Go has no runtime class extension, so SummerCMS gives you four explicit mechanisms: events, published services, optional dependencies and database callbacks. When none of them fits, you fork the plugin.

Events

The event bus from festival is the Go form of Event::listen and Event::fire. Each application has one bus, backpack.App.Events. A plugin that wants to be extensible defines an event type and fires it; other plugins listen for that type from their Boot step.

Events are routed by Go type, not by a string name, so a listener for PostPublished receives exactly that type and a payload mismatch does not compile:

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

Each listener names the plugin that owns it, so an error or a recovered panic in a listener reports which plugin failed. The bus has three dispatch modes:

  • festival.Bus.Fire runs every listener and returns their joined errors.
  • festival.Bus.Collect runs every listener and merges the payload each one adds, for events that gather contributions such as extra fields or menu items. The event implements festival.Collectable.
  • festival.Bus.UntilHandled stops at the first listener that handles the event, the WinterCMS halting fire. The event implements festival.Handleable.

festival.Bus.ListenPriority sets a priority: higher priorities run first, and equal priorities run in registration order.

Record events

The framework fires two events for every record written through the admin: pact.RecordSaved after a create or update, pact.RecordDeleted after a delete. They are fired as pointers, so a listener takes *pact.RecordSaved or *pact.RecordDeleted.

A pact.RecordSaved carries the admin controller ID (Controller), the record's morph type (Model, the model's MorphName, else its table name), its primary key as text (ID), the request locale, Created and Changes: one pact.RecordChange per changed attribute with its old and new value. A translated value is reported once per locale, with Locale set, so a translated slug change is visible too. A save that changed nothing still fires, with no changes. A pact.RecordDeleted carries Controller, Model and ID.

The events fire after the write commits, never for a write that rolled back, and synchronously before the response is written. A listener's error is logged and never fails the write. Admin creates and updates, deletes and bulk deletes, tree reorders (a moved record reports its parent and sort columns) and relation-manager child writes all fire them; a child's event names the controller that edits the child's model. A form extension's Save receives the same changes in pact.AdminFormSave.Changes, inside the save transaction.

Code that writes records outside the admin (its own API handlers, imports, record actions) fires the same events with cabana.EmitRecordSaved and cabana.EmitRecordDeleted. Pass the write's lagoon.Transaction handle and the event waits for its commit; pass nil after the write has committed and it fires at once:

app := backpack.New(nil) // in a plugin, the application it booted with

// acme.audit listens from its Boot step; record events arrive as pointers.
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
	for _, change := range ev.Changes {
		fmt.Printf("%s %s: %s %q -> %q (locale %q)\n", ev.Model, ev.ID, change.Attribute, change.Old, change.New, change.Locale)
	}
	return nil
})

// acme.blog's own API handler has saved a post outside the admin forms.
// The write has committed, so it passes no transaction and the event
// fires at once; inside a lagoon.Transaction it would pass tx and the
// event would wait for the commit.
cabana.EmitRecordSaved(context.Background(), app, nil, pact.RecordSaved{
	Model:   "acme_blog_posts",
	ID:      "42",
	Changes: []pact.RecordChange{{Attribute: "slug", Old: "hello", New: "hello-world"}},
})
// Output:
// acme_blog_posts 42: slug "hello" -> "hello-world" (locale "")

Prefer these events to a GORM callback on another plugin's model (see Model hooks and GORM callbacks): a callback sees one statement, not the admin save it belongs to, cannot tell old translated values from new ones, and runs before the transaction commits.

Services

A plugin that offers functionality to others publishes it on the container during Register with backpack.App.Publish, preferably under an interface type. Other plugins read it during Boot with backpack.App.Lookup. Because both sides use the same type, the consumer only imports the package that declares the interface, not the provider's internals. See Application lifecycle for a complete example.

Optional dependencies

A required dependency goes in party.Plugin.Requires, and activation fails when it is missing. For an integration that should work only when another plugin happens to be installed, check for it instead:

  • backpack.App.HasPlugin reports whether a plugin ID is part of this build. It answers correctly during Register, before that plugin has booted.
  • pact.OptionalMessage is a small service an optional plugin can publish so others integrate with it without importing its package.

This replaces PluginManager::exists checks in WinterCMS.

Optional here means optional at activation, not at compile time. A plugin that looks up a type declared in another plugin's package imports that package, so the other plugin still appears in its go.mod. Only party.Plugin.Requires leaves it out. pact.OptionalMessage is the way to integrate without that import.

Model hooks and GORM callbacks

A model reacts to its own lifecycle with GORM hook methods such as BeforeSave; lagoon names them as interfaces (lagoon.HasBeforeSave, lagoon.HasBeforeDelete and the rest) so you can assert them at compile time.

To react to another plugin's models, the equivalent of Post::extend with model events, register a GORM callback on the shared database handle. Boot runs before the database is open, so register it through lagoon.OnDatabase, which calls your function with the shared *gorm.DB once it is available. For work that must wait until the transaction commits, such as sending mail or publishing a realtime event, use lagoon.AfterCommit.

Replacing a plugin

When an extension point is missing, fork the plugin's module and point the application at your copy with a replace directive in its go.mod, keeping the plugin ID. The rest of the application keeps importing and requiring the original path. Go modules and workspaces shows the directive.

Prefer adding an event or a published service to the original plugin over a long-lived fork: a fork has to be kept in step with every change upstream.