--- title: Models description: Define models as GORM structs with lagoon helpers for mass assignment, hidden columns and lifecycle hooks, and keep the models package a leaf. section: database order: 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](../../modules/lagoon/README.md) 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. `lagoon.Open` refuses a database whose default collation is not the ICU `pl-PL` locale (`lagoon.CheckLocale`), so text ordering is the same in every query without a `COLLATE` clause. Create the database with that locale, as shown in [Installation](../setup/installation.md). ## 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: ```go src=modules/lagoon/example_test.go#Post // 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:"-"` } ``` ```go src=modules/lagoon/example_test.go#Post.TableName // TableName keeps the WinterCMS table name. func (Post) TableName() string { return "acme_blog_posts" } ``` The column types are described in [Casts and validation](casts-and-validation.md). Relations are ordinary GORM fields; see [Relations](relations.md). ## Mass assignment `$fillable` becomes a `Fillable` method that returns the column names mass assignment may set. The model implements `lagoon.HasFillable`: ```go src=modules/lagoon/example_test.go#Post.Fillable // 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: ```go src=modules/lagoon/example_test.go#ExampleFill 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](casts-and-validation.md): ```go src=modules/lagoon/example_test.go#create-post 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: ```go src=modules/lagoon/example_test.go#Post.Hidden // 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"} } ``` ```go src=modules/lagoon/example_test.go#ExampleHasHidden 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](../../modules/wire/README.md) helpers for Carbon-style timestamps and `[]` for empty lists. See [Routing](../services/routing.md). ## 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: ```go src=modules/lagoon/example_test.go#Post.BeforeCreate // 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 `Boot` step or from `classes`. See [Transactions](transactions.md) for registering callbacks with `lagoon.OnDatabase` and deferring their side effects until the write commits.