Skip to content

Related records

The detail page shows related records, e.g. the items of an order, in a list of the related resource: same columns, filters and actions, restricted to the records of the parent.

use Anvil\Resource\Relation;
public function relations(): array
{
return [
Relation::make('items')->resource(OrderItemResource::class)->foreignKey('order_id')->label('Items'),
];
}
Method Purpose
Relation::make('items') name ([a-z][a-z0-9_]*), unique in the resource
resource(Resource::class) the related resource (required); must be registered in the same panel
foreignKey('order_id') column of the related table that references the parent (one-to-many)
pivot(Model::class, 'parent_key', 'related_key') link through a pivot model instead (many-to-many)
label('…') heading; default: plural label of the related resource
attachable(…) Attach links existing records, see below
detachable(…) Remove unlinks records
headerActions([...]) own actions above the related records
recordActions([...]) own actions in the rows

Rules:

  • The permissions of the related resource apply: users without order-items.view do not see the section.
  • The parent record must be visible to the user, otherwise 404.
  • The list uses the query of the related resource (tenant, scopeQuery(), policies) plus the condition of the relation.
  • Related lists offer the column menu, but neither remember the layout nor offer saved views or widgets.

With several relations, show them as tabs instead of sections one below the other:

protected bool $relationTabs = true;
  • One tab per relation the user may see, in the order of relations(); each tab shows the label and the number of related records. Actions and New are inside the tab.
  • The open tab is remembered per resource in the browser tab (sessionStorage): after creating a related record or running an action the user is back on the same tab.
  • The form, components, attachments and history stay outside the tabs.
Button Enabled by Effect
New canCreate() of the related resource creates a related record linked to the parent
Attach attachable() links an existing record (foreign key set, or pivot record created)
Remove (per row) detachable() unlinks the record (foreign key cleared, or pivot record deleted)
// One-to-many: deals have a nullable contact_id.
Relation::make('deals')->resource(DealResource::class)->foreignKey('contact_id')
->attachable(titleAttribute: 'title')
->detachable(),
// Many-to-many through a pivot model (an Anvil model: tenant scoped, audited).
Relation::make('labels')->resource(LabelResource::class)
->pivot(ProjectLabelModel::class, 'project_id', 'label_id')
->attachable()
->detachable(),

php spark make:anvil-pivot ProjectLabel --parent projects --related labels generates the pivot table and model, see Commands.

New opens the create page of the related resource with the parent. The foreign key (or the pivot record) is set from the encrypted token and verified again on saving; a value submitted for the foreign key is ignored. After saving the user is back on the parent.

attachable(titleAttribute: null, scope: null, permission: null):

  • offers, in a searchable select, the records the user may see in the related resource that are not linked yet,
  • titleAttribute is shown in the select (default: the record title attribute of the related resource),
  • scope: fn (Model $model) => … restricts the records further,
  • permission replaces the default <parent slug>.update.

detachable(using: null, visible: null, permission: null, label: null, confirm: null):

  • deletes only the link, never the related record; with foreignKey() the column must be nullable,
  • asks for confirmation,
  • using: fn ($related, $data, $parent) => … replaces the default, e.g. to end a membership with a date; visible decides per row.

New, Attach and Remove require <parent slug>.update (with the policy of the parent) unless a permission is passed; New also needs canCreate() of the related resource. With a foreign key, Attach and Remove change the related record: they also need its update permission (with policy), so records of other parents cannot be moved without it. Every request checks again that the record belongs to the parent. Deleting a related record is still the Delete button of the related resource.

Domain specific membership, e.g. an assignment with a validity period, uses actions on the relation:

  • headerActions() are shown above the related records, receive the parent record and may have a form.
  • recordActions() are shown in the rows and receive the related record, the form data and the parent record; they cannot have a form (LogicException), use confirm() for a question.
Relation::make('assignments')->resource(RoleAssignmentResource::class)->foreignKey('business_role_id')
->headerActions([
Action::make('assignPerson')->label('Assign person')->icon('person-plus')
->form(fn (Form $form) => $form->schema([
BelongsTo::make('person_id')->relationship(PersonModel::class, 'last_name')->searchable()->required(),
DateInput::make('valid_from')->required(),
]))
->handle(function ($role, array $data): string {
service('assignments')->assign($role, $data);
return 'The person has been assigned.';
}),
])
->detachable(
using: fn ($assignment) => service('assignments')->end($assignment),
visible: fn ($assignment) => $assignment['ended_at'] === null,
label: 'End',
confirm: 'End this assignment today?',
);

Relation actions require <parent slug>.update unless they name their own permission(). All options of actions: Actions.