Files
summercms/docs/backend/relation-manager.md
Jakub Zych 48a5b8045a feat(12.2-03): add hasMany relation contracts, relation forms and child create
- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
2026-10-02 18:37:13 +02:00

8.8 KiB

title, description, section, order
title description section order
Relation manager Edit belongsTo and belongsToMany fields, and manage linked and hasMany records with config_relation.yaml, bound to models the controller names. backend 40

Relation manager

WinterCMS edits relations in two ways: a relation form field that picks the related record, and the Relation behaviour, which embeds a list of linked records with link and unlink buttons. cabana has both. The one rule that differs from WinterCMS: the framework never guesses a table, pivot or foreign key name. The controller supplies every name, and a missing or wrong one stops the start-up.

Relation fields

A type: relation field in fields.yaml picks a belongsTo record or a set of belongsToMany records. nameFrom names the related model's label column:

category:
    label: acme.blog::lang.posts.category
    type: relation
    nameFrom: name

The controller implements cabana.FieldRelationProvider and returns a cabana.FieldRelationContract per field: its Kind (belongsTo or belongsToMany), a factory for the related model, and the ForeignKey of a belongsTo or the pivot model and its two key columns for a belongsToMany. An optional OrderColumn on the pivot stores the order in which the administrator picked the records.

The SPA loads the choices, paginated, from .../fields/{field}/options, and every record response carries the display labels of the linked records. A controller that implements pact.RelationExtendOptionsQuery narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.

Relation managers

A type: relation-manager field embeds a relation manager in the form. Its relation key names an entry in the controller's config_relation.yaml, which describes the two panels: the linked records (view) and the candidates shown when linking (manage):

editors:
    label: acme.blog::lang.posts.editors
    view:
        list:
            columns:
                name:
                    label: acme.blog::lang.editors.name
                email:
                    label: acme.blog::lang.editors.email
        toolbarButtons: link|unlink
        showSearch: true
    manage:
        list:
            columns:
                name:
                    label: acme.blog::lang.editors.name
        showSearch: true

The controller implements cabana.AdminRelationContractProvider and returns a cabana.RelationContract per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a relation-manager field stops the start-up.

cabana.RelationService serves the panels: linked records, link candidates, link and unlink, under .../{id}/relations/{name}. Link and unlink run in a transaction. pact.RelationExtendManageQuery scopes the candidates, and pact.RelationBeforeLink can check or fill pivot columns before a link is written.

Relation kinds

RelationContract.Kind says how the related records hang off the parent:

  • belongsToMany (cabana.RelationBelongsToMany) links records through a pivot model: NewPivot, ParentForeignKey, RelatedForeignKey and optionally HookPivotColumns. A contract that leaves Kind empty is a belongsToMany, so contracts written before hasMany existed keep working unchanged.
  • hasMany (cabana.RelationHasMany) owns records through a column on the related model: ForeignKey names it. A hasMany contract declares no pivot fields; a pivot field, a ForeignKey that is not an integer column of the related model, or an unknown kind stops the start-up.
// AdminRelationContracts binds the posts form's relation managers to their
// models; the framework never guesses a table or column.
func (PostsController) AdminRelationContracts() []cabana.RelationContract {
	return []cabana.RelationContract{{
		Name:       "comments",
		Kind:       cabana.RelationHasMany,
		NewRelated: func() any { return &Comment{} },
		ForeignKey: "post_id",
		Columns:    map[string]string{"author": "author", "body": "body"},
	}}
}

A hasMany ForeignKey of a pointer type (*uint, a nullable column) is needed for unlink and for managing the relation before the parent is saved.

Relation forms

The relation manager creates and edits related records in a modal through a form of their own. manage.form names the form used to create and update a child, view.form the read-only preview; a top-level form is the fallback of both, as in WinterCMS:

comments:
    label: acme.blog::lang.posts.comments
    form: $/acme/blog/models/comment/fields.yaml
    view:
        list:
            columns:
                author:
                    label: acme.blog::lang.comments.author
        toolbarButtons: create|update|delete
    manage:
        list:
            columns:
                author:
                    label: acme.blog::lang.comments.author

A form path is plugin-relative, ~/plugins/<vendor>/<plugin>/..., or WinterCMS's $/<vendor>/<plugin>/.... A $/ path resolves inside the same plugin; a path into another plugin stops the start-up, because the plugin's embedded tree cannot read it. The form is compiled at boot like a controller form, against the related model: every field must be a column of that model, and the related model must implement lagoon.HasFillable and Rules when create or update is declared.

A relation form accepts the scalar field types (text, textarea, number, checkbox, switch, dropdown) plus datepicker and fileupload. relation, relation-manager, widget and partial stop the start-up, and so does a field named like a hasMany ForeignKey: the server sets that column from the parent.

Toolbar buttons

The view panel's toolbarButtons take WinterCMS's buttons, and each one is the capability of its routes: a route whose button the relation does not declare answers 403 forbidden, whatever the controller permission.

Button Allows
create Creating a related record through the manage form. Needs a manage form.
update Editing a related record. Needs a manage form.
delete Deleting related records.
link Linking existing records.
unlink Unlinking records. On a hasMany it needs a nullable ForeignKey.

The manage panel may declare only link. An unknown or duplicate button stops the start-up. One difference from WinterCMS: there, clicking a row opens the update form whatever the toolbar lists; here a row is editable only when update is listed.

POST .../{id}/relations/{name}/records creates a related record from the manage form's fields and attaches it to the parent, in one transaction. The parent is loaded through pact.FormExtendQuery, so a parent the administrator cannot open answers 404. The child is filled and validated like a controller save (the related model's Fill, its rules merged with the form's required flags, a 422 validation_failed envelope on failure) and its GORM hooks run. On a hasMany the server sets the ForeignKey to the parent's key; a body that names that column cannot change it. On a belongsToMany the record is inserted and its pivot row written, with pact.RelationBeforeLink stamping the hook columns.

A controller can hook into the child writes with the optional pact.RelationBeforeCreate and pact.RelationAfterCreate (and the update and delete pairs), each called with the relation name, the parent and the child. A hook error rolls the whole write back and answers the generic lifecycle error.

Messages

A relation's messages block overrides the relation manager's copy, each key a phrase key; an omitted key falls back to backend::lang.messages.relation.<key in snake_case>. The keys are link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked and empty for the link panels, and create, createTitle, updateTitle, previewTitle, created, updated, deleteSelected, deleteConfirm, deleteOneConfirm, deleted, pivotTitle, pivotSaved, editPivot, createSubmit, updateSubmit, pivotSubmit and linkSubmit for the child and pivot modals. A key that names a missing phrase stops the start-up.

Relations in lists

A list column can show a related value with relation and select in columns.yaml; see Lists and filters. A controller that maps a relation column to a physical column itself implements pact.ListRelationColumnMapper.