- lagoon.OrderBy takes variadic lagoon.OrderOption values; lagoon.Collate(name) emits a validated, double-quoted COLLATE clause (e.g. "pl-x-icu") - remove the exported CheckLocale and the ICU pl-PL check from Open and Use - framework test containers and per-test databases are plain PostgreSQL - lagoon README, root README and docs pages drop the locale requirement; queries-and-pagination gains a "Sorting with a collation" section backed by ExampleCollate
7.6 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Models | Define models as GORM structs with lagoon helpers for mass assignment, hidden columns and lifecycle hooks, and keep the models package a leaf. | database | 10 |
Models
A WinterCMS model extends Eloquent and describes its behaviour with properties such as $fillable, $hidden and $jsonable. A SummerCMS model is a plain GORM struct in the plugin's models package, and lagoon supplies the Eloquent conventions that GORM does not have: allow-listed mass assignment, JSON and encrypted columns, Laravel-style validation and pagination.
The data layer supports PostgreSQL only and puts no requirement on the database's default locale. A list that must sort text in one language's order passes a collation to lagoon.OrderBy, as described in Queries and pagination.
Defining a model
summer make:model acme.blog Post writes models/post.go and a migration that creates the table. The model is an ordinary struct with gorm column tags. Keep the WinterCMS table name with a TableName method, and use the lagoon column types where Eloquent used casts:
// Post is the acme.blog post model: a plain GORM struct with lagoon column
// types.
type Post struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
Title string `gorm:"column:title" json:"title"`
Slug string `gorm:"column:slug" json:"slug"`
Views int `gorm:"column:views" json:"views"`
Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"`
APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"`
Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"`
}
// TableName keeps the WinterCMS table name.
func (Post) TableName() string { return "acme_blog_posts" }
The column types are described in Casts and validation. Relations are ordinary GORM fields; see Relations.
Mass assignment
$fillable becomes a Fillable method that returns the column names mass assignment may set. The model implements lagoon.HasFillable:
// Fillable is the Go form of $fillable: the keys mass assignment may set.
func (Post) Fillable() []string { return []string{"title", "views"} }
lagoon.Fill copies the keys of a request map onto the model, but only keys that are also in the allow-list you pass. Other keys are dropped without an error, as Eloquent does. Outside production, pass false as the last argument and each dropped key is logged once, which catches typos in development.
A value that does not fit its column, such as text for an integer field, is a lagoon.FillTypeError whose Key names the column, so a handler can answer it as a validation error on that field:
input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"}
var post Post
// production=false logs each dropped key once, to catch typos in development.
if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil {
fmt.Println(err)
}
fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views)
err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true)
var typeErr *lagoon.FillTypeError
if errors.As(err, &typeErr) {
fmt.Println("invalid value for", typeErr.Key)
}
// Output:
// "Hello" "" 3
// invalid value for views
lagoon.Fill matches keys by the GORM column name, not the Go field name. A number decoded with json.Decoder.UseNumber fills integer and float fields; a fraction or an overflow for an integer field is a lagoon.FillTypeError.
Creating a record
A handler usually validates the input, fills the model and creates it. lagoon.Validate is described in Casts and validation:
rules := map[string]string{
"title": "required|max:255|unique:acme_blog_posts",
"views": "nullable|integer|min:0",
}
var post Post
errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil)
if err != nil || errs != nil {
return nil, errs, err
}
if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil {
return nil, nil, err
}
if err := db.WithContext(ctx).Create(&post).Error; err != nil {
return nil, nil, err
}
return &post, nil, nil
Hidden columns
$hidden becomes a Hidden method, the lagoon.HasHidden interface. It lists the columns that must never appear in a JSON response. The list documents the rule; the json:"-" tag on each field is what enforces it, because the Go JSON encoder reads struct tags, not methods:
// Hidden is the Go form of $hidden. The json:"-" tags are what keep these
// columns out of JSON; the list documents them for tooling.
func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} }
post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")}
out, _ := json.Marshal(post)
fmt.Println(string(out))
var _ lagoon.HasHidden = post
// Output:
// {"id":1,"title":"Hello","slug":"","views":0}
A lagoon.Encrypted column is also redacted when it is marshalled by mistake, so a secret never reaches a response even without the tag.
Tip
Ported endpoints rarely marshal the model itself. Build a response struct with exactly the fields the API returns, and use the wire helpers for Carbon-style timestamps and
[]for empty lists. See Routing.
Lifecycle hooks
GORM calls hook methods by name, so a model that needs beforeCreate or beforeDelete defines BeforeCreate or BeforeDelete with GORM's signature. lagoon names them as interfaces (lagoon.HasBeforeCreate, lagoon.HasBeforeSave, lagoon.HasBeforeDelete, lagoon.HasAfterDelete) so a compile-time assertion can check the signature. lagoon.HasBeforeValidate is the WinterCMS beforeValidate hook. GORM does not call it: the admin form and settings saves call it before they validate, and your own handlers call it when they need it.
A hook that only touches the model itself stays on the model:
// BeforeCreate fills the slug from the title. A hook that only touches the
// model stays on the model.
func (p *Post) BeforeCreate(tx *gorm.DB) error {
if p.Slug == "" {
p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-")
}
return nil
}
GORM runs the hook inside the transaction of the write, and an error from it aborts the write.
The models package is a leaf
A plugin's models package never imports another package of the same plugin. summer build fails when it does. This keeps the dependency graph one way: classes, controllers, console and jobs import models, never the reverse.
The rule decides where two kinds of WinterCMS model code go when you port a plugin:
- Casts and other types the model owns, such as JSON value objects, move into
models. - A hook that calls a service, such as a model event that dispatches a job, broadcasts or clears a cache, becomes a GORM callback registered from the plugin's
Bootstep or fromclasses. See Transactions for registering callbacks withlagoon.OnDatabaseand deferring their side effects until the write commits.