Files
summercms/docs/setup/porting-a-plugin.md
Jakub Zych 41a3190956 feat(11.1-05): add the acme.blog walkthrough plugin with its model, migration and posts route
- docs/examples/blog: scaffolder output for acme.blog (make:plugin, make:model)
  in the root module, with a Post model, a fill allow-list and NewPost, the
  create migration and GET /api/blog/posts paginated through lagoon
- short tests activate the plugin, check the route with surf, the fill
  allow-list and the migration order
- docs/setup/porting-a-plugin.md: registration, model, migrations and routes
  sections with src= copies of the plugin
- TestDocsRequiredPages requires setup/porting-a-plugin
2026-09-30 23:28:41 +02:00

12 KiB

title, description, section, order
title description section order
Porting a plugin 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. setup 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 first for the map of concepts.

Plugin registration

In WinterCMS, Plugin.php describes the plugin and registers what it adds:

<?php namespace Acme\Blog;

use System\Classes\PluginBase;

class Plugin extends PluginBase
{
    public function pluginDetails()
    {
        return [
            'name'        => '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:

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:

# 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 namespace Acme\Blog\Models;

use Model;

class Post extends Model
{
    public $table = 'acme_blog_posts';

    protected $fillable = ['title', 'slug', 'body'];
}

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 into a Fillable method:

// 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"`
	CreatedAt time.Time `gorm:"column:created_at"`
	UpdatedAt time.Time `gorm:"column:updated_at"`
}
// 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"} }

Post::make($input) becomes a function that fills a new post through lagoon.Fill with that allow-list. Keys outside it, such as id, are dropped, so a request can never set them:

// 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
}

See Models for the other Eloquent conventions and Casts and validation for validating the input before you fill it.

Migrations

WinterCMS lists a plugin's updates in updates/version.yaml, each version naming the migration scripts it runs:

1.0.1:
    - 'Create the posts table'
    - create_posts_table.php
<?php namespace Acme\Blog\Updates;

use Schema;
use Winter\Storm\Database\Updates\Migration;

class CreatePostsTable extends Migration
{
    public function up()
    {
        Schema::create('acme_blog_posts', function ($table) {
            $table->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:

// 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 for the history tables and the rollback commands.

Routes

A WinterCMS plugin declares its API endpoints in routes.php:

<?php

use Acme\Blog\Models\Post;

Route::get('api/blog/posts', function () {
    return Post::orderBy('created_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 with bound parameters, and answers in the {data, meta} shape of Laravel's paginator through lagoon.Paginate and wire.WriteJSON:

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 for groups, middleware and authentication, and Queries and pagination for sorting by a column the client names.