--- title: Extending plugins description: Extend other plugins through typed events, published services, optional dependencies and GORM callbacks, and replace a plugin by forking its module. section: plugins order: 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](../../modules/festival/README.md) 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: ```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 ``` 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: ```go src=modules/cabana/example_record_events_test.go#ExampleEmitRecordSaved 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](#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](../architecture/application-lifecycle.md) 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](../../modules/lagoon/README.md) 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](../architecture/go-modules-and-workspaces.md) 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.