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.

View File

@@ -0,0 +1,118 @@
---
title: Plugin registration
description: "Declare a plugin: its ID, the party.Plugin lifecycle, the pact capability interfaces it opts into, its embedded files and the scaffolded layout."
section: plugins
order: 10
---
# Plugin registration
Plugins are the foundation of every SummerCMS application. A plugin adds models, routes, admin screens, console commands, jobs and translations, and it can extend other plugins. This page covers how a plugin tells the framework what it contributes.
## Plugin identifiers
Every plugin has an ID in `vendor.plugin` form: two lower-case parts, each starting with a letter and containing only letters and digits, such as `acme.blog`. The ID is how the manifest lists the plugin, how other plugins require it and how its configuration is namespaced (`acme.blog.posts_per_page`). WinterCMS writes the same identifier as `Acme.Blog`; in SummerCMS it is always lower case.
The scaffolder derives the package and directory name from the second part, so `summer make:plugin acme.blog` creates `plugins/blog` with `package blog`.
## The plugin type
A plugin is a Go type that implements `party.Plugin`. Its package registers it from `init` with `party.Register`, so importing the package is enough to make the plugin available; the generated `plugins.gen.go` does that import for every plugin in `summer.yaml`.
| Method | Purpose |
|--------|---------|
| `party.Plugin.ID` | Returns the plugin ID. |
| `party.Plugin.Requires` | Lists the IDs of plugins that must register and boot before this one, like `$require` in WinterCMS. |
| `party.Plugin.Register` | Runs before any plugin boots. Publish services on the container here. |
| `party.Plugin.Boot` | Runs after every plugin registered. Listen to events and use other plugins' services here. |
Here is a complete plugin that also declares a backend permission:
```go src=modules/party/example_plugin_test.go
package party_test
import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// BlogPlugin is the acme.blog plugin: the Go form of a WinterCMS Plugin.php.
// A real plugin package also registers it from init with
// party.Register(&BlogPlugin{}).
type BlogPlugin struct{}
// The optional capabilities the plugin opts into, checked at compile time.
var _ pact.HasPermissions = (*BlogPlugin)(nil)
// ID is the plugin identifier in vendor.plugin form.
func (p *BlogPlugin) ID() string { return "acme.blog" }
// Requires lists the plugins that must register and boot first ($require).
func (p *BlogPlugin) Requires() []string { return []string{"acme.user"} }
// Register runs before any plugin boots: publish services here.
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
// Boot runs after every plugin registered: listen to events and look up
// services other plugins published.
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.blog.access_posts", Tab: "Blog", Label: "Manage posts"},
}
}
```
The `var _ pact.HasPermissions = (*BlogPlugin)(nil)` line is a compile-time check: if a method is missing or has the wrong signature, the build fails instead of the capability being silently ignored. Add one such line for every capability your plugin implements.
## Capability interfaces
A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS plugin implements small interfaces from [pact](../../modules/pact/README.md) instead, and the framework discovers each one with a type assertion.
| Interface | Contributes |
|-----------|-------------|
| `pact.HasRoutes` | HTTP routes, declared on a `pact.Router`. |
| `pact.HasMiddleware`, `pact.HasMiddlewareFactories` | Named and parameterized route middleware. |
| `pact.HasConfig` | Default configuration, merged under the plugin ID. |
| `pact.HasMigrations` | An ordered set of database migrations. |
| `pact.HasCommands` | Console commands for the application binary. |
| `pact.HasJobs` | Background jobs. |
| `pact.HasSchedule` | Console commands that run on a schedule; see [Scheduling](scheduling.md). |
| `pact.HasLang`, `pact.HasLangOverrides` | Translations, and overrides of other namespaces. |
| `pact.HasMailTemplates` | Mail templates and layouts. |
| `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. |
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. |
## Embedded files
Configuration defaults, translations and mail templates ship inside the binary through Go's `embed` package. The plugin returns an `fs.FS` for each:
- `pact.HasConfig.ConfigFS` returns a tree with `config/config.yaml`. Its keys become `<plugin id>.<key>`, and any other `config/<name>.yaml` becomes `<plugin id>.<name>.<key>`. The application's own `config/` directory and `SUMMER_` variables override them.
- `pact.HasLang.LangFS` returns `lang/<locale>/<group>.yaml` files.
- `pact.HasMailTemplates.MailTemplatesFS` returns the `views/mail` templates, and `pact.HasMailTemplates.MailTemplates` lists their names.
## The scaffolded layout
`summer make:plugin acme.blog` writes a plugin that compiles and follows the WinterCMS directory layout, with each directory as a Go subpackage:
```text
plugins/blog/
├── go.mod the plugin module, requiring the framework
├── plugin.go the Plugin type, its capabilities and init registration
├── routes.go the Routes method
├── registry.gen.go generated lists of models, migrations, commands, jobs and admin controllers
├── classes/ services and hooks
├── config/config.yaml default configuration
├── console/ console commands
├── controllers/ HTTP handlers and admin controllers
├── jobs/ background jobs
├── lang/en/lang.yaml translations
├── middleware/ named middleware
├── models/ GORM models
├── updates/ migrations
└── views/mail/ mail templates
```
The `make:` commands, such as `summer make:model acme.blog Post`, add files to these directories and regenerate `registry.gen.go`, so you do not edit that file by hand. The capability methods in `plugin.go` return the generated lists, so a new model, migration, command, job or admin controller is picked up without editing the plugin type.

View File

@@ -0,0 +1,83 @@
---
title: Task scheduling
description: Run a plugin's console commands on a schedule with pact.HasSchedule, and run the scheduler in the worker, as its own process or from system cron.
section: plugins
order: 20
---
# Task scheduling
WinterCMS plugins schedule work in `registerSchedule`. A SummerCMS plugin declares the same thing by implementing `pact.HasSchedule`: it returns a list of its registered console commands, each with the arguments to pass and how often to run it. Only these compiled entries ever run; there is no way to schedule an arbitrary command at runtime.
## Defining schedules
Each entry is a `pact.ScheduledCommand`: the command name in `namespace:verb` form, its arguments and a `pact.Cadence`. The command must be registered by some plugin through `pact.HasCommands`.
```go src=modules/pact/example_test.go#BlogPlugin.Schedule
// Schedule runs three of the plugin's registered console commands.
func (p *BlogPlugin) Schedule() []pact.ScheduledCommand {
return []pact.ScheduledCommand{
{Command: "blog:prune-drafts", Cadence: pact.Daily()},
{Command: "blog:send-digest", Cadence: pact.DailyAt(7, 30)},
{Command: "blog:sync-feed", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)},
}
}
```
Build a cadence with one of three functions:
| Function | Runs | Laravel equivalent |
|----------|------|--------------------|
| `pact.Daily` | Every day at 00:00. | `->daily()` |
| `pact.DailyAt` | Every day at the given hour and minute. | `->dailyAt('07:30')` |
| `pact.Every` | At every multiple of the interval since midnight, so `pact.Every(15 * time.Minute)` runs at :00, :15, :30 and :45. | `->everyFifteenMinutes()` |
Times are wall-clock times in the `app.timezone` location (UTC when it is not set). On a daylight saving day a daily entry keeps its wall-clock time.
The scheduler checks every entry when a worker starts, and the start fails with an error naming the plugin ID and the entry index when:
- the command name is empty or the cadence is the zero `pact.Cadence`;
- a daily hour or minute is out of range;
- an `pact.Every` interval is shorter than one second or does not divide 24 hours evenly.
A command that no plugin registers does not fail the start: each run logs a warning and is skipped.
You can inspect a cadence with `pact.Cadence.At` (the hour and minute of a daily cadence) and `pact.Cadence.Interval` (24 hours for a daily cadence). This example prints the entries above:
```go src=modules/pact/example_test.go#ExampleHasSchedule
var plugin pact.HasSchedule = &BlogPlugin{}
for _, entry := range plugin.Schedule() {
if hour, minute, daily := entry.Cadence.At(); daily {
fmt.Printf("%s %v: daily at %02d:%02d\n", entry.Command, entry.Args, hour, minute)
continue
}
fmt.Printf("%s %v: every %s\n", entry.Command, entry.Args, entry.Cadence.Interval())
}
// Output:
// blog:prune-drafts []: daily at 00:00
// blog:send-digest []: daily at 07:30
// blog:sync-feed [--quiet]: every 15m0s
```
## How schedules run
The schedule runs inside the background job worker from [conga](../../modules/conga/README.md). Every worker turns each entry into a periodic job with the ID `<plugin id>[<index>]:<command>`, for example `acme.blog[0]:blog:prune-drafts`. When several instances of the application run, one worker is elected leader and only the leader enqueues due runs, so each period runs once across all instances, even when the leader changes mid-period.
Each run is a job on the `scheduled` queue with a single attempt: an interrupted run is not retried, and the next period runs normally. The worker calls the command in-process and logs its output line by line.
The worker runs in `serve` by default. When you run workers separately (`queue.work_in_serve` set to `false`), `./bin/acme queue:work` carries the schedule too.
## Running the scheduler on its own
To run only the scheduler in its own process, use `schedule:run`. It starts a worker on the `scheduled` queue and runs until it receives SIGINT or SIGTERM:
```sh
./bin/acme schedule:run
```
If you prefer system cron, as in Laravel, use `schedule:run --once` every minute. It runs, without the job queue, every entry that is due in the current minute and exits:
```sh
* * * * * cd /srv/acme && ./bin/acme schedule:run --once
```
`schedule:run --once` has no overlap lock: two runs in the same minute run the due entries twice, as Laravel does. From the application directory during development, `summer schedule:run --once` runs the same command through the built binary.

48
docs/plugins/testing.md Normal file
View File

@@ -0,0 +1,48 @@
---
title: Testing plugins
description: Test plugins with go test, run database tests against real PostgreSQL containers, replay API parity fixtures and keep documentation examples running.
section: plugins
order: 40
---
# Testing plugins
SummerCMS uses the standard Go test tooling. There is no separate test runner and no PHPUnit bootstrap: a plugin's tests are `_test.go` files next to its code, and `go test` runs them.
## Running tests
Run every test in the module from its root:
```sh
go vet ./...
go test ./...
```
Tests that need Docker, such as database tests, skip themselves in short mode. Use it for a fast loop:
```sh
go test -short ./...
```
In an application with local plugins in a `go.work` workspace, run the tests of one plugin by its directory, for example `go test ./plugins/blog/...`.
## Unit tests without a database
Most plugin code runs without a database. Build a container with `backpack.New`, call your plugin's Register and Boot, and assert on what it published. Drive HTTP handlers with `net/http/httptest`: `surf.Assemble` builds the same handler `serve` uses, so a test can send requests to your routes without listening on a port. Call console commands in-process with `bonfire.Call`.
## Database tests
SummerCMS supports PostgreSQL only, so database tests run against real PostgreSQL rather than an SQLite stand-in. The framework's own tests start a `postgres:16-alpine` container through testcontainers-go and create the database with the ICU `pl-PL` locale that lagoon checks for when it connects. Follow the same pattern in plugin tests:
- skip the test when `testing.Short` reports true;
- create the database with `LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`, or lagoon refuses the connection;
- migrate a fresh database per test with `lagoon.Migrate`, so tests do not depend on each other.
Docker must be running for these tests.
## API parity tests
When you port an existing backend, its real responses are the acceptance test. [tide](../../modules/tide/README.md) records request and response fixtures from the reference backend and replays them against your port, reporting differences after masking IDs and timestamps. The `summer parity:record`, `summer parity:replay`, `summer parity:proxy` and `summer parity:broadcasts` commands wrap it.
## Examples in the documentation
Every Go code block in these docs is a copy of an `Example` function or a marked region of a test that `go test ./...` runs. If you change a framework API and forget an example, `go test` fails. Write your plugin's examples the same way: an `Example` function with an `// Output:` comment is compiled, run and compared by `go test`, so it cannot go stale.