feat(11.1-03): add the Architecture and Plugins docs sections

- architecture: introduction, Go modules and workspaces, application
  lifecycle, request lifecycle
- plugins: registration, scheduling, extending, testing
- verified Examples for backpack services, towel request context,
  pact schedules and festival events
- TestDocsRequiredPages lists the eight new pages
This commit is contained in:
Jakub Zych
2026-09-30 22:12:08 +02:00
parent d6003cd84b
commit 1f8f5e1b51
14 changed files with 749 additions and 0 deletions

70
docs/plugins/extending.md Normal file
View File

@@ -0,0 +1,70 @@
---
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.
## 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.
## 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.