--- 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. ## 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.