--- 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 and run its migrations 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 'Blog', 'description' => 'A simple blog', 'author' => 'Acme', ]; } public function boot() { } } ``` 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: ```go src=docs/examples/blog/plugin.go package blog import ( "embed" "io/fs" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/go-gormigrate/gormigrate/v2" ) 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) ) //go:embed config var configFS embed.FS //go:embed lang var langFS embed.FS //go:embed views/mail var mailFS 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) Commands() []bonfire.Command { return generatedCommands() } func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } func (p *Plugin) AdminControllers() []pact.AdminController { return generatedAdminControllers() } func init() { party.Register(&Plugin{}) } ``` `Boot` keeps the application, because the route handler below reaches the database through it. The capability methods return `generatedModels()`, `generatedMigrations()` 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. 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 Post model The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable`: ```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/`, 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 and the rollback commands. ## Routes A WinterCMS plugin declares its API endpoints in `routes.php`: ```php 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 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"` CreatedAt wire.Time `json:"created_at"` } // listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of // posts, newest first, in the {data, meta} shape of Laravel's paginator. 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{}) var total int64 if err := q.Count(&total).Error; err != nil { wire.WriteOpaque500(w) return } var posts []models.Post if err := q.Order("created_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, CreatedAt: wire.Time{Time: post.CreatedAt.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. `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.