Files
summercms/docs/setup/porting-a-plugin.md
Jakub Zych 8bc18c90b6 feat(build): scaffold working admin controllers with model, AdminFS and delete (quick-261028-pq8)
- make:admin-controller edits the singular of its name (--model overrides):
  ModelName, modelClass and the YAML under models/<model>/
- NewRecord returns &models.<Model>{} when the model exists; otherwise the
  nil body stays and the command says to run make:model
- config_list.yaml offers create and delete with showCheckboxes
- registry.gen.go embeds controllers/*/*.yaml and models/*/*.yaml once they
  exist and exposes generatedAdminFS(); scaffolded plugins implement
  pact.AdminAssets through it, and a plugin without AdminFS gets a note
- blog example moves its YAML to models/post; docs and help text follow
2026-10-28 19:15:23 +01:00

839 lines
33 KiB
Markdown

---
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/acme/blog
summer build
```
| Command | Writes |
|---------|--------|
| `summer make:plugin acme.blog` | `plugins/acme/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/<timestamp>_create_acme_blog_posts.go` |
| `summer make:migration acme.blog AddPublishedAt` | `updates/<timestamp>_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/post/fields.yaml` and `models/post/columns.yaml` |
| `summer make:command acme.blog Publish` | `console/publish.go` |
| `summer plugin:add plugins/acme/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/acme/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
<?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:
```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
// 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, which receive the application
// kept in Boot, plus blog:publish. That one takes the narrower withDB, which
// its tests replace, so it is added here by hand.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
}
// AdminFS is the admin YAML the controllers read, controllers/posts and
// models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
// 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` returns `generatedAdminFS()`, the admin YAML `registry.gen.go` embeds, and `Commands` returns `generatedCommands(p.app)`; both come with the scaffold. `Permissions` and `Navigation` were added by hand for the backend, and `Commands` appends 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
scopes:
blog: Blog
blog_description: Full access to blog admin APIs.
blog_read: Blog (read)
blog_read_description: Read-only access to blog admin APIs.
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
<?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:
```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
<?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. Each new migration gets a timestamp after every file already in `updates/`, so the order is the order you created them in. The example uses fixed timestamps, `20260101000000` and `20260101000100`; yours carry the time you ran the command. 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
<?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`:
```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
<?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 for the model `Post`, the singular of the controller name (`--model` names another one). Because `make:model` already wrote `models.Post`, the generated `NewRecord` returns it. 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:
```go src=docs/examples/blog/controllers/posts.go
//summer:make admin-controller
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`:
```yaml src=docs/examples/blog/controllers/posts/config_list.yaml
list: ~/plugins/acme/blog/models/post/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
```
```yaml src=docs/examples/blog/controllers/posts/config_form.yaml
name: acme.blog::lang.posts.post
form: ~/plugins/acme/blog/models/post/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/post/`, the model's name, as in WinterCMS; `make:admin-controller` puts them there:
```yaml src=docs/examples/blog/models/post/fields.yaml
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
```
```yaml src=docs/examples/blog/models/post/columns.yaml
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](../backend/forms.md) 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:
```go src=docs/examples/blog/plugin.go#Plugin.AdminFS
// AdminFS is the admin YAML the controllers read, controllers/posts and
// models/post, which registry.gen.go embeds.
func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }
```
```go src=docs/examples/blog/plugin.go#Plugin.Permissions
// 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](../backend/admin-controllers.md) for the admin API and its hooks, and [Users and permissions](../backend/users-and-permissions.md) for granting the permission.
## Console command
The WinterCMS artisan command publishes a post by its slug:
```php
<?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`, built by a function that takes the `*backpack.App` the plugin kept from `Boot`; through it a command reaches the config and the database. This command takes something narrower instead, a `withDB` function its tests can replace without an application:
```go src=docs/examples/blog/console/publish.go
//summer:make command
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",
Long: `Sets published_at on the post with this slug. Publishing a post twice keeps
the first publication time. It fails when no post has the slug.`,
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
Examples: []bonfire.Example{
{Command: "blog:publish hello-world", Description: "Publish the post hello-world"},
},
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
})
},
}
}
```
The generated accessor lists command functions that take no parameter or a single `*backpack.App`, and passes them the application. `PublishCommand` takes `withDB` instead, so the accessor leaves it out; 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, which receive the application
// kept in Boot, plus blog:publish. That one takes the narrower withDB, which
// its tests replace, so it is added here by hand.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(p.app), 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 `//summer:make` line stays.** Files the `make:` commands write for you to edit start with `//summer:make <kind>`, such as `//summer:make model`. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it (or the older `// Code generated by summer make. DO NOT EDIT.` header), so a model, migration, command, job or admin controller whose line you delete disappears from the accessors the next time you run a `make:` command. Only `registry.gen.go` itself is marked as generated, so linters check the files you edit.
- **Declare the admin permission.** The generated controller requires `acme.blog.access_posts`; declare it in the plugin's `Permissions()` (`pact.HasPermissions`), or the admin API refuses to start with an unknown-permission error.
- **Give the model `Fillable` and `Rules`.** The admin API saves only the fields `Fillable` lists and validates them with `Rules`; until the model has them, a save writes nothing.
- **Fill in `fields.yaml` and `columns.yaml`.** The scaffold lists only `id`; add the model's fields and columns. Run `make:model` before `make:admin-controller`, or `NewRecord` returns `nil` until you return the model yourself.
- **A command with a narrower dependency is added by hand.** The function `make:command` writes receives the `*backpack.App` the plugin kept from `Boot`, and the generated accessor lists such functions. A command that takes something else, such as the `withDB` of `blog:publish`, is left out of the accessor; return it from `Commands` yourself.
## 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.