--- title: Admin controllers description: Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records. section: backend order: 10 --- # Admin controllers A WinterCMS backend controller extends `Backend\Classes\Controller`, implements the List, Form and Relation behaviours, and describes its screens in `config_list.yaml`, `config_form.yaml` and the model's `columns.yaml` and `fields.yaml`. SummerCMS keeps the YAML, and drops the controller class: [cabana](../../modules/cabana/README.md) compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas. ## Declaring a controller A controller is a small Go type that implements `pact.AdminController`: its ID, the model name that `modelClass` in the YAML must match, and the directory that holds its YAML. It usually also implements `pact.AdminRecordSource`, which returns the GORM model to query, and `pact.AdminPermissioned`, the permissions an administrator needs. The plugin returns its controllers from `pact.HasAdminControllers` and its embedded YAML tree from `pact.AdminAssets`: ```go src=modules/cabana/example_controller_test.go package cabana_test import ( "io/fs" "os" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) // Post is the model behind the acme.blog posts controller. type Post struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Slug string `gorm:"column:slug"` Status string `gorm:"column:status"` Published bool `gorm:"column:published"` PublishedAt *time.Time `gorm:"column:published_at"` Content string `gorm:"column:content"` } func (Post) TableName() string { return "acme_blog_posts" } // Fillable lists the columns the admin form may write. func (Post) Fillable() []string { return []string{"title", "slug", "status", "published", "content"} } // PostsController is the admin controller for posts: the Go form of a // WinterCMS controller with the List and Form behaviours. type PostsController struct{} var ( _ pact.AdminController = PostsController{} _ pact.AdminRecordSource = PostsController{} _ pact.AdminPermissioned = PostsController{} _ pact.HasAdminControllers = (*BlogPlugin)(nil) ) // ID is the controller ID; its admin API path is /acme/blog/posts. func (PostsController) ID() string { return "acme.blog.posts" } // ModelName must equal modelClass in the YAML. func (PostsController) ModelName() string { return "Post" } // ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml. func (PostsController) ConfigDir() string { return "controllers/posts" } // NewRecord returns the model the generic admin handlers query. func (PostsController) NewRecord() any { return &Post{} } // RequiredPermissions are checked before any schema or query. func (PostsController) RequiredPermissions() []string { return []string{"acme.blog.access_posts"} } // BlogSettings is the singleton row behind the plugin's settings page. type BlogSettings struct { ID uint `gorm:"column:id;primaryKey"` PostsPerPage int `gorm:"column:posts_per_page"` CommentsEnabled bool `gorm:"column:comments_enabled"` } func (BlogSettings) TableName() string { return "acme_blog_settings" } func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} } // Rules validates a settings save, as a WinterCMS settings model's $rules. func (BlogSettings) Rules() map[string]string { return map[string]string{"posts_per_page": "required|integer|between:1,100"} } // BlogPlugin is the acme.blog plugin; only its backend surface is shown. type BlogPlugin struct{} var ( _ pact.AdminAssets = (*BlogPlugin)(nil) _ pact.HasPermissions = (*BlogPlugin)(nil) _ pact.HasNavigation = (*BlogPlugin)(nil) _ pact.HasSettings = (*BlogPlugin)(nil) ) func (p *BlogPlugin) ID() string { return "acme.blog" } func (p *BlogPlugin) Requires() []string { return nil } func (p *BlogPlugin) Register(app *backpack.App) error { return nil } func (p *BlogPlugin) Boot(app *backpack.App) error { return nil } // AdminControllers registers the plugin's admin controllers. func (p *BlogPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{PostsController{}} } // AdminFS is the plugin's embedded controllers/ and models/ tree. A real // plugin returns an embed.FS; the example reads the same files from testdata. func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") } // Permissions replaces registerPermissions(). func (p *BlogPlugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"}, {Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"}, } } // Navigation replaces registerNavigation(). func (p *BlogPlugin) 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", SideMenu: []pact.NavigationItem{ {Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"}, }, }} } // Settings replaces registerSettings(). func (p *BlogPlugin) Settings() []pact.SettingsItem { return []pact.SettingsItem{{ Code: "blog", Label: "acme.blog::lang.settings.label", Description: "acme.blog::lang.settings.description", Category: "acme.blog::lang.plugin.name", Icon: "icon-pencil", Model: "BlogSettings", Permissions: []string{"acme.blog.access_settings"}, Form: "models/settings/fields.yaml", NewModel: func() any { return &BlogSettings{} }, }} } ``` `summer make:admin-controller` writes the controller type and its four YAML files: ```sh summer make:admin-controller acme.blog Posts ``` The generated controller requires the permission `acme.blog.access_posts`, so declare it in the plugin's `pact.HasPermissions` or the admin API refuses to start with an unknown-permission error. Its `NewRecord` returns `nil` until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: `acme.blog.posts` is served under `/api/v1/acme/blog/posts`. The admin prefix is `backend.uri`, `/backend` by default. A model's `Fillable` method decides which form fields the API may write; see [Forms](forms.md). The admin finds a model's columns through its struct fields, including those of an embedded struct such as `gorm.Model`. A field with a `gorm:"column:..."` tag is known by that column alone; an untagged field is known by GORM's default column name. ## Compilation at boot At start-up `cabana.Activate` compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a `modelClass` that does not match `pact.AdminController.ModelName`, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above: ```go src=modules/cabana/example_test.go#ExampleActivate cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) if err != nil { fmt.Println(err) return } // Set through SUMMER_ADMIN__JWT__SECRET in a real deployment. _ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes") _ = cfg.Set("backend.uri", "/admin") routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}}) if err != nil { fmt.Println(err) return } fmt.Println(routes.Prefix) editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}} fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions())) fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"})) // Output: // /admin // true // false ``` `admin.jwt.secret` is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed. ## The admin API Every controller gets the same routes, relative to `/api/v1/{vendor}/{plugin}/{controller}`: | Method and path | Does | |-----------------|------| | `GET /schema/list`, `GET /schema/form` | The list and form schemas, translated into the request locale. | | `GET /` | Lists records with search, sort, filters and pagination, allowed only on columns the schema declares. | | `POST /` | Creates a record. | | `GET /{id}`, `PUT /{id}`, `DELETE /{id}` | Shows, updates and deletes a record. | | `POST /bulk-delete` | Deletes a set of records in one transaction. | The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field. Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction. ## Hooks Behaviour overrides such as `formBeforeCreate` or `listExtendQuery` become optional interfaces on the controller. cabana checks for each one and calls it at the matching point: | Interface | Runs | |-----------|------| | `pact.ListExtendQuery` | Scopes every list query, for example to the administrator's own records. | | `pact.FormExtendQuery` | Scopes every show, update and delete lookup, so a record outside the scope is a 404. | | `pact.FormBeforeCreate`, `pact.FormAfterCreate` | Around a create, inside its transaction. | | `pact.FormBeforeUpdate`, `pact.FormAfterUpdate` | Around an update, inside its transaction. | | `pact.FormBeforeDelete`, `pact.FormAfterDelete` | Around a delete, inside its transaction. | | `pact.DropdownOptionsProvider` | Supplies the options of a `dropdown` field that names a method. | Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes. ## Toolbar actions `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.