Writing
Writing relations
Create, update, attach, detach, sync, toggle and delete related rows in a mutate entry, and write pivot fields with their rules.
The entries under relations write the related rows of a row. The operations that an entry accepts depend on the family of the relation.
app/Rest/Resources/TeamResource.php
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsTo;
use Lomkit\Rest\Relations\BelongsToMany;
use Lomkit\Rest\Relations\HasMany;
public function relations(): array
{
return [
BelongsTo::make('company', CompanyResource::class),
HasMany::make('tickets', TicketResource::class)->deletable(),
BelongsToMany::make('members', UserResource::class)->pivotFields([
Text::make('role')->creationRules('required'),
Integer::make('weight')->nullable(),
]),
];
}
Operations by relation family
| Relation | Entries | create | update | attach | detach | sync | toggle | delete |
|---|---|---|---|---|---|---|---|---|
BelongsTo, MorphTo | One object | Yes | Yes | Yes | Yes | |||
HasOne, HasOneOfMany, MorphOne, MorphOneOfMany | One object | Yes | Yes | Yes | Yes | With deletable() | ||
HasMany, MorphMany | A list | Yes | Yes | Yes | Yes | With deletable() | ||
BelongsToMany, MorphToMany, MorphedByMany | A list | Yes | Yes | Yes | Yes | Yes | Yes | |
HasOneThrough, HasManyThrough | None |
- An operation that the relation does not take gives
The selected mutate.0.relations.products.0.operation is invalid. - A to-many relation takes a list of entries, and
keythere can be a list of keys. A to-one relation takes one object. - A custom relation lists its operations in
mutationOperations(), asLomkit\Rest\Fields\MutationOperationcases. A relation that returns[]takes no entry.deletecomes fromdeletable()only: aDeletecase in that list is ignored. - The details list the operations that each relation accepts, under
operations, with?with=relations.operations.
POST /api/teams/mutate
{
"mutate": [
{
"operation": "update",
"key": 1,
"relations": {
"company": {"operation": "attach", "key": 4},
"tickets": [
{"operation": "create", "attributes": {"title": "Printer jam"}},
{"operation": "update", "key": 12, "attributes": {"status": "closed"}},
{"operation": "detach", "key": 13}
],
"members": [
{"operation": "attach", "key": [7, 8], "pivot": {"role": "agent"}},
{"operation": "sync", "key": [9], "without_detaching": true, "pivot": {"weight": 2}}
]
}
}
]
}
200 OK
{
"operations": [
{
"operation": "update",
"key": 1,
"relations": {
"company": {"operation": "attach", "key": 4},
"tickets": [
{"operation": "create", "key": 31},
{"operation": "update", "key": 12},
{"operation": "detach", "key": 13}
],
"members": [
{"operation": "attach", "key": [7, 8]},
{"operation": "sync", "key": [9]}
]
}
}
]
}
Owner relations: BelongsTo and MorphTo
createwrites a new owner,attachlinks an existing one, and both set the foreign key of the row. The entry is written before the row that holds it.detachnames the current owner of the row, and on aMorphToitstype. Another row, or adetachunder a created row, givesThe selected mutate.0.relations.author.key is invalid.- A
MorphToentry sendstype, its morph type, required when the relation has several targets:{"operation": "attach", "type": "book", "key": 2}. Its response entry answerstypetoo. - When a resource keeps the morph type column of a
MorphTowritable, the column accepts the morph types of its targets only. Declare the morph columnsreadonly().
Child relations: HasOne, HasMany and the morph forms
createwrites a child with the key of the parent, and on the morph relations its morph type (getMorphClass(), so the morph-map alias).updateanddetachreach only a child of the parent: a row whose foreign key, and morph type, names the parent. Another row givesThe selected mutate.0.relations.tickets.0.key is invalid., and so does any row under a created parent.attachtakes a row of another parent and moves it.detachclears the foreign key of the child, and the morph type on the morph relations.attachandcreateunder aHasOneorMorphOnerelation give a 422 while the parent already has a related row:The mutate.0.relations.loan field is prohibited: this relation already holds a record. Detach it first.Detach the current row in an entry written before. The of-many forms take another row.
POST /api/books/mutate
{
"mutate": [
{"operation": "update", "key": 2, "relations": {"loan": {"operation": "detach", "key": 5}}},
{"operation": "update", "key": 2, "relations": {"loan": {"operation": "attach", "key": 7}}}
]
}
These checks run during validation, before any policy, hook or write, in the order of the entries: an entry sees what the entries before it attach, detach, create and delete.
Deleting child rows
A child relation that declares deletable() accepts delete entries: the rows are deleted in the transaction of the mutation, next to the other writes.
HasMany::make('tickets', TicketResource::class)->deletable(),
POST /api/teams/mutate
{
"mutate": [
{
"operation": "update",
"key": 101,
"relations": {
"tickets": [
{"operation": "update", "key": 101, "attributes": {"status": "closed"}},
{"operation": "delete", "key": [103]},
{"operation": "delete", "key": 102}
]
}
}
]
}
200 OK
{
"operations": [
{
"operation": "update",
"key": 101,
"relations": {
"tickets": [
{"operation": "update", "key": 101},
{"operation": "delete", "key": [103]},
{"operation": "delete", "key": 102}
]
}
}
]
}
deletable()exists onHasOne,HasOneOfMany,HasMany,MorphOne,MorphOneOfManyandMorphMany. Without it, at the root ofmutate, and onBelongsTo,MorphToand many-to-many relations,deletegivesThe selected mutate.0.relations.tickets.0.operation is invalid.keyis a key, or a list of keys under a to-many relation. Adeleteentry takes noattributesand norelations:The mutate.0.relations.tickets.0.attributes field is prohibited when mutate.0.relations.tickets.0.operation is delete.- Each key must name a child of the parent that the
perimeterQuery()of the related resource returns, when the entry is written. Another key givesThe selected mutate.0.relations.tickets.0.key is invalid. - A deleted row that another entry of the request also names gives
The mutate.0.relations.tickets.1.key field has a duplicate value. - Before any write, each row is checked with the
deleteability of the related resource, as ondestroy, and with the link check ofdetach: thedetachability of the relation, or elseupdateon the parent. A refusal answers403, and nothing is written. - The row goes through the
performDelete()of the related resource: aSoftDeletesmodel is soft deleted, never force deleted, and the model events run. mutatingAll(),mutatedAll()andResourceMutatedsee the deletions. Thedestroying(),destroyed(),destroyingAll()anddestroyedAll()hooks of thedestroyendpoint do not run: put a rule that holds on both endpoints in the policy or in a model observer.- Each deleted key counts as one entry toward
rest.limits.sizes.relation_entries:The mutate.0.relations.tickets field goes over the limit of 100 entries, each deleted key counting as one entry.
Many-to-many relations
createwrites a related row and its pivot row.attachadds pivot rows for existing rows.detachremoves them.synckeeps exactly the listed keys among the attached rows, and with"without_detaching": trueonly adds the missing ones.toggleattaches each listed key that is not attached, and detaches each listed key that is.syncandtogglecompare their keys with the attached rows that theperimeterQuery()of the related resource returns. Other attached rows stay.syncandtoggleanswer the keys that they sent. The rows that they detach or attach besides these keys are not listed.update,syncandtoggleacceptattributesfor the related row, with its updatable fields.attachanddetachtake noattributes:The mutate.0.relations.tags.0.attributes field is prohibited when mutate.0.relations.tags.0.operation is attach.without_detachingon another operation givesThe mutate.0.without_detaching field is prohibited unless mutate.0.operation is in sync.
Pivot fields
A many-to-many relation declares its pivot columns with typed fields:
use Lomkit\Rest\Fields\DateTime;
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;
BelongsToMany::make('tags', TagResource::class)->pivotFields([
Integer::make('weight')->nullable()->rules('min:0'),
Text::make('added_by')->updatable(false), // written on create and attach only
Text::make('note')->writeOnly(), // written, never returned
DateTime::make('created_at')->readonly(), // returned, never written
]),
{"operation": "attach", "key": 3, "pivot": {"weight": 5, "added_by": "import"}}
createandattachadd a pivot row, so they accept the creatable pivot fields, withcreationRules().update,syncandtoggleaccept the updatable pivot fields, withupdateRules().detachrefusespivot.- A
readonly()pivot field is refused on every operation. AwriteOnly()pivot field is accepted. - Another key gives a 422 under
pivot:The created_at key of the mutate.0.relations.tags.0.pivot field is not writable on create.Onupdate,syncandtoggle, it ends withon update. - Each value is validated under its key,
mutate.0.relations.tags.0.pivot.weight: the base rule of its type,nullable,rules(), and the rules of the operation. The rules run whenpivotis missing too, so a field withcreationRules('required')is required onattach:The mutate.0.relations.tags.0.pivot.weight field is required. - A rule that names another pivot field, such as
required_with:note, compares with that key in the pivot of the same entry. update,syncandtogglevalidate with the updatable fields, also when they add a pivot row, because validation runs before the database is read. Useattachto add a row with the creation rules.- A pivot field declared
virtual(), or withfillUsing()orresolveUsing(), throwsInvalidFieldException, and so does a string or a name declared twice. - An
Enumpivot field reads its enum class from the casts of the pivot class, the class given tousing()or Laravel'sPivot. - In a search, the
pivotof an included row holds the selectable pivot fields only, and filters ontags.pivot.weightaccept the filterable ones, see Filters.
Link checks
Attaching or detaching a row checks the link on the policy of the parent's resource. A relation can name its own abilities:
BelongsToMany::make('tags', TagResource::class)
->abilities(attach: 'attachTag', detach: 'detachTag'),
app/Policies/BookPolicy.php
public function attachTag(User $user, Book $book, Tag $tag): bool
{
return $user->can('update', $book) && ! $tag->archived;
}
- Without
abilities(), a link checksupdateon the parent. - A declared ability without a policy method is denied.
- A refused link answers
403before any write. See Policies.