Files
summercms/docs/setup/porting-a-plugin.md
Jakub Zych dd82b8a2ad feat(11.1-05): add the walkthrough's admin controller, publish command and published_at migration
- make:admin-controller, make:command and make:migration output, finished:
  the Posts controller serves models.Post behind acme.blog.access_posts,
  WinterCMS-style form and list YAML embedded through pact.AdminAssets,
  blog:publish sets published_at by slug with a bound parameter
- the posts route lists published posts only, newest first
- Docker tests migrate an ICU pl-PL database, roll back published_at, serve
  the route and run blog:publish with a published and an opened database
- the page gains the admin controller, console command and added-column
  sections
2026-09-30 23:35:44 +02:00

26 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, run its command and run its migrations up and down 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 Backend;
use System\Classes\PluginBase;

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

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:

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

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

use Model;

class Post extends Model
{
    use \Winter\Storm\Database\Traits\Validation;

    public $table = 'acme_blog_posts';

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

    protected $dates = ['published_at'];

    public $rules = [
        'title' => '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:

// 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"`
}
// 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"} }
// 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:

// 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 for the other Eloquent conventions and Casts and validation for the rule strings.

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
1.0.2:
    - 'Add the publication date'
    - add_published_at.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/, 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:

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

Adding a column

The second WinterCMS update adds the publication date:

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:

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

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

use Acme\Blog\Models\Post;

Route::get('api/blog/posts', function () {
    return Post::whereNotNull('published_at')
        ->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:

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 for groups, middleware and authentication, and Queries and pagination 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 namespace Acme\Blog\Controllers;

use BackendMenu;
use Backend\Classes\Controller;

class Posts extends Controller
{
    public $implement = [
        \Backend\Behaviors\ListController::class,
        \Backend\Behaviors\FormController::class,
    ];

    public $listConfig = 'config_list.yaml';
    public $formConfig = 'config_form.yaml';

    public $requiredPermissions = ['acme.blog.access_posts'];

    public function __construct()
    {
        parent::__construct();
        BackendMenu::setContext('Acme.Blog', 'blog');
    }
}

summer make:admin-controller acme.blog Posts writes controllers/posts.go and four YAML files. In SummerCMS the controller has no actions or views: the framework's generic admin API lists, shows, creates, updates and deletes records, and the admin SPA draws the screens from the YAML. The controller only says which model it serves, where its YAML is and which permission it needs:

// Code generated by summer make. DO NOT EDIT.

package controllers

import (
	"git.golem15.com/golem15/summercms/docs/examples/blog/models"
	"git.golem15.com/golem15/summercms/modules/pact"
)

var (
	_ pact.AdminController   = postsAdmin{}
	_ pact.AdminRecordSource = postsAdmin{}
	_ pact.AdminPermissioned = postsAdmin{}
)

// postsAdmin is the Go form of the Posts backend controller with the List
// and Form behaviours.
type postsAdmin struct{}

func (postsAdmin) ID() string        { return "acme.blog.posts" }
func (postsAdmin) ModelName() string { return "Post" }
func (postsAdmin) ConfigDir() string { return "controllers/posts" }

// NewRecord returns the model the generic admin handlers query and fill.
func (postsAdmin) NewRecord() any { return &models.Post{} }

// RequiredPermissions replaces $requiredPermissions: an administrator needs
// this permission before any schema or record is served.
func (postsAdmin) RequiredPermissions() []string {
	return []string{"acme.blog.access_posts"}
}

// PostsController returns the acme.blog.posts admin controller.
func PostsController() pact.AdminController { return postsAdmin{} }

RequiredPermissions replaces $requiredPermissions and is checked before any schema or record is served. NewRecord gives the admin API the model to query, and its Fillable and Rules decide what a save may write. The YAML keeps its WinterCMS syntax; modelClass must equal ModelName:

list: ~/plugins/acme/blog/models/posts/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id
recordsPerPage: 20
showCheckboxes: true
defaultSort:
    column: published_at
    direction: desc
toolbar:
    buttons: [create, delete]
    search:
        prompt: backend::lang.list.search_prompt
name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/posts/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts

create:
    redirect: acme/blog/posts/update/:id
    redirectClose: acme/blog/posts

update:
    redirect: acme/blog/posts
    redirectClose: acme/blog/posts

The form and list fields sit under models/posts/, the controller's name, where make:admin-controller puts them (WinterCMS keeps them under models/post/, the model's name):

fields:
    title:
        label: acme.blog::lang.posts.title_field
        type: text
        span: left
        required: true
    slug:
        label: acme.blog::lang.posts.slug
        type: text
        span: right
        required: true
    body:
        label: acme.blog::lang.posts.body
        type: textarea
        size: large
columns:
    title:
        label: acme.blog::lang.posts.title_field
        searchable: true
    slug:
        label: acme.blog::lang.posts.slug
        searchable: true
    published_at:
        label: acme.blog::lang.posts.published_at
        type: datetime

The WinterCMS form had a date picker for published_at. SummerCMS forms have no date picker (see Forms for the field types), so the form leaves the column out and blog:publish sets it; the list still shows it as a datetime column. published_at is not in Fillable either, so no form save can set it.

The plugin embeds the YAML through pact.AdminAssets and declares the permission and the menu entry, as registerPermissions and registerNavigation did:

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

See Admin controllers for the admin API and its hooks, and Users and permissions for granting the permission.

Console command

The WinterCMS artisan command publishes a post by its slug:

<?php namespace Acme\Blog\Console;

use Acme\Blog\Models\Post;
use Illuminate\Console\Command;

class Publish extends Command
{
    protected $signature = 'blog:publish {slug}';

    protected $description = 'Publish a blog post by its slug';

    public function handle()
    {
        $post = Post::where('slug', $this->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:

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

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

summer build
./bin/acme blog:publish hello-world

See Writing commands for arguments, flags, prompts and output.