--- title: Porting a plugin description: Take a WinterCMS acme/blog plugin with a model, migrations, a route, a backend controller and an artisan command to a compiled SummerCMS plugin, step by step. section: setup order: 50 --- # Porting a plugin This walkthrough ports a small WinterCMS plugin, `Acme.Blog`, to SummerCMS. The plugin has what most real plugins have: a registration class, a `Post` model, `version.yaml` updates, a `routes.php` API endpoint, a backend `Posts` controller with its YAML, and an artisan command. Each section shows the WinterCMS file first and the SummerCMS file that replaces it. The name `acme/blog` is a neutral example. The SummerCMS code on this page is not a sketch: every Go and YAML block is a copy of a file under `docs/examples/blog` in the framework repository, a compiled plugin whose tests activate it, serve its route, run its command and run its migrations up and down against PostgreSQL. Read [Coming from WinterCMS](coming-from-wintercms.md) first for the map of concepts. ## Scaffold it yourself Every file of the plugin starts as scaffolder output. From the application directory, these commands produce the same file layout as `docs/examples/blog`, which a test in the framework checks: ```sh summer make:plugin acme.blog summer make:model acme.blog Post summer make:migration acme.blog AddPublishedAt summer make:admin-controller acme.blog Posts summer make:command acme.blog Publish summer plugin:add plugins/blog summer build ``` | Command | Writes | |---------|--------| | `summer make:plugin acme.blog` | `plugins/blog` as its own Go module: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod`, `config/config.yaml`, `lang/en/lang.yaml`, `views/mail/welcome.htm` and a `doc.go` in `classes`, `console`, `controllers`, `jobs`, `middleware`, `models` and `updates` | | `summer make:model acme.blog Post` | `models/post.go` and `updates/_create_acme_blog_posts.go` | | `summer make:migration acme.blog AddPublishedAt` | `updates/_add_published_at.go` | | `summer make:admin-controller acme.blog Posts` | `controllers/posts.go`, `controllers/posts/config_form.yaml`, `controllers/posts/config_list.yaml`, `models/posts/fields.yaml` and `models/posts/columns.yaml` | | `summer make:command acme.blog Publish` | `console/publish.go` | | `summer plugin:add plugins/blog` | The plugin in `summer.yaml`, a `require` and a local `replace` in the application's `go.mod`, and the directory in `go.work` | | `summer build` | `bin/acme`, the application binary with the plugin compiled in | Each `make:` command also rewrites `registry.gen.go` and runs `go mod tidy` in the plugin. The sections below fill in what each generated file leaves empty. See [Scaffolding](../console/scaffolding.md) for every option of these commands. The copy in the framework repository has no `go.mod`, because it is a package of the framework module so that the framework's own `go test ./...` covers it. A plugin you scaffold is a module of its own, and its `go.mod` starts like this for an application whose module is `example.com/acme` (indirect requirements left out): ```text module example.com/acme/plugins/blog go 1.27.0 toolchain go1.27.0 require ( git.golem15.com/golem15/summercms v0.0.0 github.com/go-gormigrate/gormigrate/v2 v2.1.7 gorm.io/gorm v1.31.2 ) replace git.golem15.com/golem15/summercms => ../../../summercms.go ``` The `replace` points at the same framework checkout as the application's `go.mod`, so the plugin builds against your local framework while you develop. ## Plugin registration In WinterCMS, `Plugin.php` describes the plugin and registers what it adds: ```php 'acme.blog::lang.plugin.name', 'description' => 'acme.blog::lang.plugin.description', 'author' => 'Acme', ]; } public function register() { $this->registerConsoleCommand('blog.publish', \Acme\Blog\Console\Publish::class); } public function registerPermissions() { return [ 'acme.blog.access_posts' => [ 'tab' => 'acme.blog::lang.plugin.name', 'label' => 'acme.blog::lang.permissions.access_posts', ], ]; } public function registerNavigation() { return [ 'blog' => [ 'label' => 'acme.blog::lang.plugin.name', 'url' => Backend::url('acme/blog/posts'), 'icon' => 'icon-pencil', 'permissions' => ['acme.blog.access_posts'], ], ]; } } ``` In SummerCMS the plugin is a Go type in the plugin's root package, `plugin.go`. `summer make:plugin acme.blog` writes it with every capability a new plugin usually needs: embedded config, language and mail files, models, migrations, commands, jobs and admin controllers. The plugin registers itself from `init`, and the application imports the package so that `init` runs. Here is the finished file; the sections below explain each part: ```go src=docs/examples/blog/plugin.go package blog import ( "context" "embed" "io/fs" "git.golem15.com/golem15/summercms/docs/examples/blog/console" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) var ( _ pact.HasConfig = (*Plugin)(nil) _ pact.HasLang = (*Plugin)(nil) _ pact.HasMailTemplates = (*Plugin)(nil) _ pact.HasModels = (*Plugin)(nil) _ pact.HasMigrations = (*Plugin)(nil) _ pact.HasCommands = (*Plugin)(nil) _ pact.HasJobs = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil) _ pact.AdminAssets = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) _ pact.HasNavigation = (*Plugin)(nil) ) //go:embed config var configFS embed.FS //go:embed lang var langFS embed.FS //go:embed views/mail var mailFS embed.FS //go:embed controllers/*/*.yaml models/*/*.yaml var adminFS embed.FS // Plugin is the acme.blog plugin, the Go form of Plugin.php. type Plugin struct { app *backpack.App } func (p *Plugin) ID() string { return "acme.blog" } func (p *Plugin) Requires() []string { return nil } func (p *Plugin) Register(*backpack.App) error { return nil } // Boot keeps the application, so route handlers and commands can reach its // services, such as the database, when they run. func (p *Plugin) Boot(app *backpack.App) error { p.app = app return nil } func (p *Plugin) ConfigFS() fs.FS { return configFS } func (p *Plugin) LangFS() fs.FS { return langFS } func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } func (p *Plugin) MailTemplates() []string { return nil } func (p *Plugin) MailLayouts() map[string]string { return nil } func (p *Plugin) Models() []any { return generatedModels() } func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() } func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } func (p *Plugin) AdminControllers() []pact.AdminController { return generatedAdminControllers() } // Commands returns the generated commands plus blog:publish, which needs the // database and so is built here with the plugin's withDB. func (p *Plugin) Commands() []bonfire.Command { return append(generatedCommands(), console.PublishCommand(p.withDB)) } // AdminFS is the admin YAML the controllers read: controllers/posts and // models/posts. func (p *Plugin) AdminFS() fs.FS { return adminFS } // Permissions replaces registerPermissions(). func (p *Plugin) Permissions() []pact.Permission { return []pact.Permission{{ Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.access_posts", }} } // Navigation replaces registerNavigation(). func (p *Plugin) Navigation() []pact.NavigationItem { return []pact.NavigationItem{{ Code: "blog", Label: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Permissions: []string{"acme.blog.access_posts"}, Controller: "acme.blog.posts", }} } // withDB runs fn with the application's database: the one the serve command // published, or, when a console command runs, one opened from the config for // the duration of fn. func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) } sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) if err != nil { return err } defer sqlDB.Close() return fn(gdb) } func init() { party.Register(&Plugin{}) } ``` Each `var _ pact.HasX = (*Plugin)(nil)` line is a compile-time check that the plugin really implements a capability; the application finds the capabilities by type assertion. `Boot` keeps the application, because the route handler and the command reach the database through it. `Models`, `Migrations`, `Jobs` and `AdminControllers` return `generatedModels()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller. `AdminFS`, `Permissions` and `Navigation` were added by hand for the backend, and `Commands` for the console command. The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin's `config/config.php`. They are merged under the plugin ID, so this value is read as `acme.blog.per_page`: ```yaml src=docs/examples/blog/config/config.yaml # Defaults for acme.blog, merged under the plugin ID: the application reads # this value as acme.blog.per_page and can override it in its own config. per_page: 15 ``` The language strings stay in YAML, one file per locale, and keep the WinterCMS `acme.blog::lang.` keys: ```yaml src=docs/examples/blog/lang/en/lang.yaml plugin: name: Blog description: A simple blog. permissions: access_posts: Manage blog posts posts: title: Posts post: Post title_field: Title slug: Slug body: Body published_at: Published ``` ## The Post model The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable` and its validation rules in `$rules`: ```php 'required|max:255', 'slug' => 'required|max:255|unique:acme_blog_posts', ]; } ``` `summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is a GORM struct; add its columns, keep the table name with `TableName`, and turn `$fillable` and `$rules` into methods: ```go src=docs/examples/blog/models/post.go#Post // Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post // model. type Post struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` Body string `gorm:"column:body"` PublishedAt *time.Time `gorm:"column:published_at"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } ``` ```go src=docs/examples/blog/models/post.go#Post.Fillable // Fillable is the Go form of $fillable: the only columns lagoon.Fill may // set from a request. func (Post) Fillable() []string { return []string{"title", "slug", "body"} } ``` ```go src=docs/examples/blog/models/post.go#Post.Rules // Rules is the Go form of $rules. The admin API checks them with // lagoon.Validate on every save; unique ignores the post being updated. func (Post) Rules() map[string]string { return map[string]string{ "title": "required|max:255", "slug": "required|max:255|unique:acme_blog_posts", } } ``` `Post::make($input)` becomes a function that fills a new post through `lagoon.Fill` with that allow-list. Keys outside it, such as `id` or `published_at`, are dropped, so a request can never set them: ```go src=docs/examples/blog/models/post.go#NewPost // NewPost is the Go form of Post::make($input): it copies only the fillable // keys of input onto a new post and drops the rest, such as id. func NewPost(input map[string]any) (*Post, error) { post := &Post{} if err := lagoon.Fill(post, post.Fillable(), input, true); err != nil { return nil, err } return post, nil } ``` The admin API uses the same `Fillable` list and `Rules` for every save. See [Models](../database/models.md) for the other Eloquent conventions and [Casts and validation](../database/casts-and-validation.md) for the rule strings. ## Migrations WinterCMS lists a plugin's updates in `updates/version.yaml`, each version naming the migration scripts it runs: ```yaml 1.0.1: - 'Create the posts table' - create_posts_table.php 1.0.2: - 'Add the publication date' - add_published_at.php ``` ```php increments('id'); $table->string('title'); $table->string('slug')->unique(); $table->text('body'); $table->timestamps(); }); } public function down() { Schema::dropIfExists('acme_blog_posts'); } } ``` In SummerCMS each migration is a gormigrate entry in `updates/`, in a file named after a UTC timestamp so the files sort in the order they run. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it: ```go src=docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go#CreatePosts // CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry. func CreatePosts() *gormigrate.Migration { return &gormigrate.Migration{ ID: "20260101000000_create_acme_blog_posts", Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE acme_blog_posts ( id BIGSERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, body TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS acme_blog_posts").Error }, } } ``` `summer migrate` runs every plugin's pending migrations in plugin order. See [Migrations](../database/migrations.md) for the history tables. ### Adding a column The second WinterCMS update adds the publication date: ```php Schema::table('acme_blog_posts', function ($table) { $table->timestamp('published_at')->nullable(); }); ``` `summer make:migration acme.blog AddPublishedAt` writes an empty migration in `updates/` and adds it to the plugin's list. Fill in `Migrate` and give `Rollback` a real inverse: ```go src=docs/examples/blog/updates/20260101000100_add_published_at.go#AddPublishedAt // AddPublishedAt returns the 20260101000100_add_published_at gormigrate entry. func AddPublishedAt() *gormigrate.Migration { return &gormigrate.Migration{ ID: "20260101000100_add_published_at", Migrate: func(tx *gorm.DB) error { return tx.Exec("ALTER TABLE acme_blog_posts ADD COLUMN published_at TIMESTAMPTZ NULL").Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec("ALTER TABLE acme_blog_posts DROP COLUMN IF EXISTS published_at").Error }, } } ``` Add the `PublishedAt` field to the model (shown above), then apply the migration. While you develop, roll back the plugin's last migration, edit it and apply it again: ```sh summer migrate summer migrate:rollback --plugin acme.blog summer migrate ``` `migrate:rollback` undoes one migration of one plugin, here `published_at` only; the posts table stays. ## Routes A WinterCMS plugin declares its API endpoints in `routes.php`: ```php orderBy('published_at', 'desc') ->paginate(15); }); ``` The SummerCMS plugin implements `pact.HasRoutes` in `routes.go` and declares the route on a `pact.Router`. The handler reads the database the application published, counts and loads one page of published posts with bound parameters, and answers in the `{data, meta}` shape of Laravel's paginator through `lagoon.Paginate` and `wire.WriteJSON`: ```go src=docs/examples/blog/routes.go package blog import ( "net/http" "strconv" "time" "git.golem15.com/golem15/summercms/docs/examples/blog/models" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) var _ pact.HasRoutes = (*Plugin)(nil) // Routes replaces routes.php. func (p *Plugin) Routes(r pact.Router) error { r.Get("/api/blog/posts", p.listPosts) return nil } // postJSON is the response shape of one post. It is built field by field, // so a column added to the model never leaks into the API. type postJSON struct { ID uint `json:"id"` Title string `json:"title"` Slug string `json:"slug"` Body string `json:"body"` PublishedAt wire.Time `json:"published_at"` } // listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of // published posts, newest first, in the {data, meta} shape of Laravel's // paginator. Drafts, whose published_at is NULL, are never listed. func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) { db, ok := p.app.Lookup[*gorm.DB]() if !ok { wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "database unavailable"}) return } page := queryInt(r, "page", 1, 1, 10000) perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100) q := db.WithContext(r.Context()).Model(&models.Post{}).Where("published_at IS NOT NULL") var total int64 if err := q.Count(&total).Error; err != nil { wire.WriteOpaque500(w) return } var posts []models.Post if err := q.Order("published_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { wire.WriteOpaque500(w) return } rows := make([]postJSON, 0, len(posts)) for _, post := range posts { rows = append(rows, postJSON{ ID: post.ID, Title: post.Title, Slug: post.Slug, Body: post.Body, PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)}, }) } wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) } // queryInt reads an integer query parameter, falling back to def when it is // missing or not a number, and clamps it to [lo, hi]. func queryInt(r *http.Request, name string, def, lo, hi int) int { n, err := strconv.Atoi(r.URL.Query().Get(name)) if err != nil { n = def } return min(max(n, lo), hi) } ``` The response is built from a separate `postJSON` type rather than the model, so a column added later does not appear in the API by accident, and `wire.Time` writes timestamps in the form Laravel does (`2026-01-03T10:00:00+00:00`). `page` and `per_page` are clamped, so a client cannot ask for the whole table at once. See [Routing](../services/routing.md) for groups, middleware and authentication, and [Queries and pagination](../database/queries-and-pagination.md) for sorting by a column the client names. ## Admin controller The WinterCMS backend controller implements the List and Form behaviours, requires a permission and names its YAML: ```php argument('slug'))->firstOrFail(); $post->published_at = $post->published_at ?: now(); $post->save(); $this->info('published ' . $post->slug); } } ``` `summer make:command acme.blog Publish` writes `console/publish.go` with a `bonfire.Command` named `blog:publish`. The command needs the database, which only the plugin can reach, so the finished function takes it as a parameter: ```go src=docs/examples/blog/console/publish.go // Code generated by summer make. DO NOT EDIT. package console import ( "context" "fmt" "git.golem15.com/golem15/summercms/docs/examples/blog/models" "git.golem15.com/golem15/summercms/modules/bonfire" "gorm.io/gorm" ) // PublishCommand returns the blog:publish console command. withDB runs the // command's work with the application's database; the plugin supplies it. func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command { return bonfire.Command{ Name: "blog:publish", Description: "Publish a blog post by its slug", Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { slug, _ := in.Argument("slug") if slug == "" { return fmt.Errorf("blog:publish: a slug is required") } return withDB(ctx, func(db *gorm.DB) error { // A bound parameter, never the slug spliced into SQL. Publishing // twice keeps the first publication time. res := db.WithContext(ctx).Model(&models.Post{}). Where("slug = ?", slug). Update("published_at", gorm.Expr("COALESCE(published_at, NOW())")) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("blog:publish: no post has the slug %q", slug) } out.Printf("published %s\n", slug) return nil }) }, } } ``` Because the function now takes a parameter, the generated accessor no longer lists it; the plugin adds it in `Commands` and passes its `withDB`, which uses the database the server published or, when the command runs from the console, opens one from the application config for the command's duration: ```go src=docs/examples/blog/plugin.go#Plugin.Commands // Commands returns the generated commands plus blog:publish, which needs the // database and so is built here with the plugin's withDB. func (p *Plugin) Commands() []bonfire.Command { return append(generatedCommands(), console.PublishCommand(p.withDB)) } ``` ```go src=docs/examples/blog/plugin.go#Plugin.withDB // withDB runs fn with the application's database: the one the serve command // published, or, when a console command runs, one opened from the config for // the duration of fn. func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) } sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) if err != nil { return err } defer sqlDB.Close() return fn(gdb) } ``` Build the application and run the command on its binary: ```sh summer build ./bin/acme blog:publish hello-world ``` See [Writing commands](../console/writing-commands.md) for arguments, flags, prompts and output. ## What the scaffolder leaves to you The `make:` commands write files that compile, not a finished plugin. These are the steps this walkthrough had to do by hand, and the scaffolder behaviour behind them: - **The generated-code header stays.** Files the `make:` commands write start with `// Code generated by summer make. DO NOT EDIT.`, although you are meant to edit them. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it, so a model, migration, command or admin controller whose header you delete disappears from the accessors the next time you run a `make:` command. Linters also treat these files as generated and skip them. - **Check the migration order.** A migration's file name and ID start with the second it was created in. Migrations with different names created in the same second get the same timestamp and run in file-name order, so `add_published_at` would run before `create_acme_blog_posts` and fail on the missing table. Look at `updates/` after scaffolding; if two files share a timestamp, rename the later one and its ID. The example uses fixed timestamps, `20260101000000` and `20260101000100`. - **The admin controller names the model after itself.** `make:admin-controller acme.blog Posts` returns `Posts` from `ModelName` and writes `modelClass: Posts`, and it puts `fields.yaml` and `columns.yaml` under `models/posts/`. Change `ModelName` and both `modelClass` values to the model, `Post`; the YAML can stay where it is, as in this example. - **The admin controller needs more than the scaffolder writes.** The generated controller implements only `pact.AdminController`. Add `NewRecord` (`pact.AdminRecordSource`) so the admin API has a model to query, and `RequiredPermissions` (`pact.AdminPermissioned`): without it, any signed-in administrator can open the controller. The model needs `Fillable` and `Rules` before the admin API can save it. The plugin needs `AdminFS` (`pact.AdminAssets`) to embed the YAML; without it the application refuses to start once the admin is enabled. - **A command that needs the application takes it as a parameter.** The function `make:command` writes takes no arguments, which is what the generated accessor looks for, so it cannot reach the database or the config. Give it the dependency as a parameter and return it from `Commands` yourself, as `blog:publish` does. ## Checklist What changed on the way from WinterCMS to SummerCMS: - `Plugin.php` became a `Plugin` type in `plugin.go`, registered from `init` with `party.Register`; each `register*` method became a capability interface such as `pact.HasPermissions` or `pact.HasNavigation`. - The plugin is a Go module the application imports; `summer plugin:add` and `summer build` replace dropping a directory into `plugins/`. - The Eloquent model became a GORM struct; `$fillable` and `$rules` became `Fillable` and `Rules` methods, and every mass assignment goes through `lagoon.Fill`. - `version.yaml` and the update scripts became timestamped gormigrate entries, each with a real `Rollback`. - `routes.php` became `Routes` on a `pact.Router`, with handlers that answer through a response type of their own and `lagoon.Paginate`. - The backend controller class became a `pact.AdminController` with a model, a permission and the same YAML; the generic admin API replaces the behaviours and their views. - The artisan command became a `bonfire.Command`, run with `./bin/acme blog:publish`. - Language strings and config defaults stay in YAML files embedded in the binary.