Mutate
Every resource answers POST /{resource}/mutate. One request creates and updates rows, writes their relations, and runs as one database transaction: either every entry is written, or none.
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsToMany;
use Lomkit\Rest\Relations\HasMany;
class CategoryResource extends Resource
{
public static string $model = Category::class;
public function fields(): array
{
return [
ID::make('id'),
Text::make('name')->rules('max:255')->creationRules('required'),
];
}
public function relations(): array
{
return [
HasMany::make('products', ProductResource::class),
BelongsToMany::make('featuredProducts', ProductResource::class),
];
}
}
{
"mutate": [
{
"operation": "create",
"attributes": {"name": "Lighting"},
"relations": {
"products": [
{"operation": "create", "attributes": {"name": "Desk lamp", "price": 39}},
{"operation": "attach", "key": [4, 5]}
]
}
},
{"operation": "update", "key": 2, "attributes": {"name": "Seating"}}
]
}
{
"operations": [
{
"operation": "create",
"key": 3,
"relations": {
"products": [
{"operation": "create", "key": 9},
{"operation": "attach", "key": [4, 5]}
]
}
},
{"operation": "update", "key": 2}
]
}
Entries
| Key | Taken by | Value |
|---|---|---|
operation | Every entry | create or update at the root; attach, detach, sync, toggle and delete too under relations, see Relations |
key | Every operation but create | The key of the row, typed like the ID field of the resource. A list of keys only in an entry of a to-many relation |
attributes | create, update, sync, toggle | The values of the fields that the operation writes |
relations | Every operation but delete | The entries of each relation, by name: an object for a to-one relation, a list for a to-many relation |
version | update, on a versioned resource | The version of the row that the client read, see Versioned rows |
pivot | Entries of a many-to-many relation | The pivot values, see Pivot fields |
without_detaching | sync | true keeps the rows that key does not list |
type | Entries of a MorphTo relation | The morph type of the row |
mutatemust be a JSON list:The mutate field must be a list.- A key that an entry does not take gives a 422 on that key:
The mutate.0.atributes field is prohibited.,The mutate.0.key field is prohibited when mutate.0.operation is create. attributes,relationsandpivotmust be objects.confirmation, next tomutate, carries the token of a confirmation. It must be a string:The confirmation field must be a string.- An unknown key at the first level of the body is ignored.
Attributes
attributes accepts the fields that the operation can write, each validated by its field.
ID::make('id'), // refused on create and update
ID::make('uuid')->creatable(), // a key that clients send on create
Text::make('title')->rules('max:255')->creationRules('required'),
Number::make('rating')->nullable(), // a number or null
DateTime::make('created_at')->readonly(), // refused on create and update
Text::make('slug')->updatable(false), // written on create only
Text::make('api_token')->writeOnly(), // written, never returned
createaccepts the creatable fields, andupdate,syncandtogglethe updatable fields. A field is creatable and updatable by default.IDfields andreadonly()fields are not.- A
virtual()field is refused unless it declaresfillUsing(), which runs after the other fields of the entry. - Another key gives a 422 under
attributes:The created_at key of the mutate.0.attributes field is not writable on create.Onupdate,syncandtoggle, the message ends withon update. - Each attribute is validated by the base rule of its type,
nullable,rules(), andcreationRules()oncreateorupdateRules()on the other operations.nullis refused unless the field isnullable(). - A rule that names another attribute, such as
required_if:featured,true, compares with that attribute in the same entry. - The resource rules,
rules(),createRules()andupdateRules(), still apply, for rules that compare attributes and for nested keys such asmetadata.source. - The model is filled with the writable fields of the operation only, after the
mutating()hook.
See Fields and Validation for the field declarations and rules.
Rows that a write reaches
- The keys of
mutate, at the root and under relations, are read through theperimeterQuery()of the resource of their row, during validation. A key outside it givesThe selected mutate.0.key is invalid., as a key that does not exist, and dispatches theKeysOutOfReachevent. See Perimeter. - A created row is not checked against
perimeterQuery(). - Each entry that names the same row writes the same model instance, in the order of the entries, so it sees what the entries before it saved.
- A row that another request deletes between the validation and the write is not written, and the mutation answers 200.
Nested writes
relations holds the entries of each relation of the row, by relation name. An entry can hold its own relations.
- A
BelongsToorMorphToentry is written before the row that holds it. The other relations are written after it. - The keys that the parent relation sets win over the attributes of the same name: the key of the parent on
HasOne,HasMany,MorphOne,MorphManyand their of-many forms, and the morph type on the morph relations. OnBelongsToandMorphTo, the foreign key and the morph type that the relation sets win over the attributes of the entry. - An entry under an entry with a list of keys is written once for each of these keys.
relationsnest up torest.limits.depths.mutationslevels, 3 by default. A deeperrelationsgivesThe mutate.0.relations.products.0.relations.category.relations.products.0.relations field is prohibited.requiredOnCreation(),prohibitedOnCreation(),requiredOnUpdate()andprohibitedOnUpdate()on a relation require or refuse its entries:The mutate.0.relations.products field is required.HasOneThroughandHasManyThroughtake no entry:The mutate.0.relations.replies field is prohibited.Write through the intermediate relation instead.
The operations of each relation family are listed on Relations.
The response
The response holds operations: one entry for each entry of mutate, at the same index, and one for each entry of its relations.
operationis the operation of the entry.keyis the key of the row that acreatewrote. On another operation, it is thekeythat the entry sent, typed like theIDfield:"2"answers2on an integer key.- An entry that sends
relationsanswersrelations, under the names that it sent, in the same order: an object for a to-one relation, a list for a to-many relation. An entry that sends no relation answers norelationskey. - An entry with a list of keys answers once, with the list. A
createunder such an entry answers the list of the keys that it created, in the order of the keys above:
{"operation": "attach", "key": [1, 2], "relations": {"category": {"operation": "create", "key": [7, 8]}}}
- An entry of a
MorphTorelation also answerstype, the morph type of its row:{"operation": "attach", "type": "book", "key": 2}. syncandtoggleanswer the keys that they sent.deleteanswers the keys that it deleted, as sent.
Reading the written rows
Send response next to mutate to get the written rows back, rendered as a search renders its rows:
{
"mutate": [
{"operation": "update", "key": 2, "attributes": {"title": "Dune Messiah"}},
{"operation": "create", "attributes": {"title": "Ivanhoe"}}
],
"response": {
"selects": [{"field": "title"}],
"includes": [{"relation": "labels", "selects": [{"field": "text"}]}],
"gates": ["update"]
}
}
{
"operations": [
{"operation": "update", "key": 2},
{"operation": "create", "key": 3}
],
"records": [
{"title": "Dune Messiah", "gates": {"authorized_to_update": {"allowed": true, "message": null}}, "labels": [{"text": "Classic"}], "@more": []},
{"title": "Ivanhoe", "gates": {"authorized_to_update": {"allowed": true, "message": null}}, "labels": [], "@more": []}
]
}
recordsholds one record for each entry ofmutate, at the same index. Two entries on the same row give the same record. The entries underrelationsgive no record: request their rows throughincludes.responsetakesselects,aggregates,includesandgates, with the rules of a search at every level. An include keeps itslimitfor each record, and a record with a to-many include ends with@more.- Another key of a search gives a 422, such as
The response.filters field is prohibited., and so doescreateingates:The response.gates.0 field is prohibited.Aresponsethat is not an object givesThe response field must be an array. - The records are read after the commit, through the search of the resource: its
perimeterQuery()applies, and a row that it leaves out, or that is gone, givesnull. - The gates answer for the rows as the mutation left them. The records go through the
map()of the resource's Response. responseneedsviewAnyon the resource and on each resource that its includes and aggregates reach, checked before any write: a denial answers403, and nothing is written.
Transactions
The mutation runs in one database transaction, on the connection of the model of the endpoint's resource:
- The class checks (
viewAny,create) run before validation. - Validation reads the rows that the keys name, through the perimeter.
- In the transaction, the row checks of every entry run, before the first write, then the version check of a versioned resource.
- The resource's
mutateConfirmation()can ask for a confirmation: the endpoint then answers428and writes nothing. mutatingAll(), the writes with theirmutating()andmutated()hooks, thenmutatedAll().- After the commit, the
recordsare read and theResourceMutatedevent is dispatched.
- A failure anywhere, a denied row, a hook that throws or a database error, rolls the whole mutation back, and the response is the error alone.
- Rows of models on another connection are written outside the transaction.
mutating()andmutated()run for each row that an entry writes, at every level, on the resource of that row: acreate, anupdate, an entry withattributes, and anattach,detach,syncortoggleentry whose ownrelationschange.- An entry that writes no attribute and no relation, such as the target of an
attach, runs no hook and is not saved: only its link is written.
{"operation": "attach", "key": 7, "relations": {"tags": [{"operation": "attach", "key": [3]}]}}
The entry above links row 7 to its parent and attaches tag 3 to row 7: the hooks of its resource run for row 7, and tag 3, only linked, runs none.
See Events and hooks.
Deadlock retries
'transactions' => [
'attempts' => env('REST_TRANSACTION_ATTEMPTS', 1),
],
- When the database reports a deadlock or a lock timeout, the package runs the transaction again, up to
attemptstimes in all.1, the default, runs it once. - This applies to the transaction of a mutation, of
destroy,restoreandforceDestroy, and of each chunk, row or whole run of an action. - Each attempt rolls the previous one back, gives the rows read before the transaction their attributes as read, and runs everything inside the transaction again: the row checks, the hooks, the events and the
handle()orhandleRow()of an action. - Send mails, HTTP calls and jobs after the commit, with
afterCommit(),ShouldDispatchAfterCommitor a listener of the package events, so that they run once. - Laravel runs only the outermost transaction again: inside a transaction of your application, a deadlock throws
Illuminate\Database\DeadlockExceptionat once.
Recording each written row
To keep a history row for each written row, write it from the row hooks. They run inside the transaction of the endpoint: a throw in a hook, or later in the request, rolls back the write and the history together, and a deadlock retry runs the hooks again after it rolls back what the failed attempt wrote.
use Illuminate\Database\Eloquent\Model;
use Lomkit\Rest\Mutations\MutationNode;
public function mutated(MutationNode $entry, Model $model): void
{
History::create([
'subject_type' => $model->getMorphClass(),
'subject_id' => $model->getKey(),
'note' => trim($entry->operation->value.' '.implode(',', array_keys($entry->relations ?? []))),
]);
}
{
"mutate": [
{
"operation": "update",
"key": 101,
"attributes": {"borrower": "Alan"},
"relations": {
"book": {
"operation": "attach",
"key": 102,
"relations": {
"shelves": [
{"operation": "create", "attributes": {"name": "Essays"}},
{"operation": "attach", "key": [101]}
]
}
}
}
}
]
}
The hooks run from the deepest row up, and the history gets three rows in the transaction of the mutation: the new shelf with create, book 102 with attach shelves, then loan 101 with update book. Shelf 101, only linked, runs no hook.
- On
mutate, usemutated(): the row and its relations are written by then.$entry->attributesand$entry->relationssay what the request changed for$model. Read a relation inmutated()for the rows now attached. - A target that is only linked, a moved child and a child
deleterun no hook of their own: record them from the entry of the parent row, under$entry->relations. - On
destroy,restoreandforceDestroy, usedestroyed(),restored()andforceDestroyed(), once per row. - In an action, write the history inside
handle()orhandleRow(), in the transaction of the chunk, the row or the run.ActionTransaction::Noneopens no transaction. - The Laravel events run after the commit: keep them for work outside the database.
Authorization
viewAnyon the resource of the endpoint is checked before validation.createis checked once per created resource class, before validation, then per created row with its draft.updateis checked per updated row, with the changes of the entry as a third argument.- A row that an entry names without writing it, such as the target of an
attachon a many-to-many relation, is checked withview. A row that an entry moves under a child relation is checked withupdate. - The links of
attach,detach,syncandtogglecheck the abilities that the relation declares, or elseupdateon the parent. - A denial answers
403with the message of the first denial:{"message": "This action is unauthorized."}, or the message of the policy'sResponse::deny().
See Policies for the checks and for the denials list.
Limits
| Limit | Default | Counts |
|---|---|---|
rest.limits.sizes.mutate | 100 | The entries of mutate |
rest.limits.sizes.relation_entries | 100 | The entries of each to-many relation, each deleted key counting as one |
rest.limits.sizes.values | 100 | The keys of one key list |
rest.limits.depths.mutations | 3 | The levels of relations |
rest.limits.totals.entries | 1000 | Every list entry of the request |
rest.limits.sizes.rows | 10000 | The rows that response can render: one record for each entry of mutate, times the limit of each to-many include. See Limits |
Errors
{
"message": "The created_at key of the mutate.0.attributes field is not writable on create.",
"errors": {
"mutate.0.attributes": ["The created_at key of the mutate.0.attributes field is not writable on create."]
}
}
| Status | Example |
|---|---|
403 | A policy refuses a row: {"message": "This action is unauthorized."} |
409 | A versioned row changed since the client read it, see Versioned rows |
428 | The resource asks for a confirmation, see Confirmation |
422 | The selected mutate.0.operation is invalid. |
422 | The selected mutate.0.key is invalid. |
422 | The mutate.0.attributes.name field must be a string. |
422 | The mutate.0.key field is prohibited when mutate.0.operation is create. |
422 | The mutate.0.without_detaching field is prohibited unless mutate.0.operation is in sync. |
Searching outside HTTP
Replay a client search payload from a job, a command or an export with Rest::search(), with the validation, perimeter and policies of the endpoint.
Writing relations
Create, update, attach, detach, sync, toggle and delete related rows in a mutate entry, and write pivot fields with their rules.