Writing

Versioned rows

Refuse an update of a row that another request changed since the client read it, with a 409 that lists every conflict.

Two users open the same product, both edit it, both save: without a check, the second save silently overwrites the first. A versioned resource lets each update entry send the version of the row that the client read. When the row has changed since, the whole mutation answers 409 and writes nothing.

app/Rest/Resources/ProductResource.php
use Lomkit\Rest\Fields\DateTime;
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;

public function fields(): array
{
    return [
        ID::make('id'),
        Integer::make('category_id'),
        Text::make('name'),
        Integer::make('price'),
        DateTime::make('updated_at')->readonly(),
    ];
}

public function versioned(): bool
{
    return true;
}

The version of a row is its updated_at, as the search returns it.

Sending a version

POST /api/products/mutate
{
  "mutate": [
    {"operation": "update", "key": 1, "version": "2026-01-01T08:00:00.000000Z", "attributes": {"name": "Desk light"}}
  ],
  "response": {
    "selects": [{"field": "updated_at"}]
  }
}
200 OK
{
  "operations": [{"operation": "update", "key": 1}],
  "records": [{"updated_at": "2026-03-01T10:00:00.000000Z"}]
}
  • version is optional: an update entry without it writes as before.
  • To save a row twice, read its new version from records, as above.
  • Every entry is compared with the version that the row had before the first write of the request: two entries on the same row can send the same version.

Conflicts

When a row has another version, the mutation answers 409, runs no hook, dispatches no ResourceMutated and writes nothing, not even the entries that hold no conflict:

POST /api/products/mutate
{
  "mutate": [
    {"operation": "update", "key": 1, "version": "2026-01-01T08:00:00.000000Z", "attributes": {"name": "Desk light"}},
    {"operation": "update", "key": 2, "version": "2026-01-02T08:00:00.000000Z", "attributes": {"price": 99}},
    {"operation": "create", "attributes": {"category_id": 1, "name": "Wall lamp", "price": 45}}
  ]
}
409 Conflict
{
  "message": "The record was changed by another request.",
  "conflicts": [
    {"path": "mutate.1", "key": 2, "version": "2026-03-01T10:00:00.000000Z"}
  ]
}
  • conflicts lists every entry whose version differs: its path in the payload, the key of the row, and the version of the row now, or null when the row is gone.
  • A client shows the conflict, reloads the row and lets the user save again with the new version.

Nested entries and key lists

  • Entries under relations accept version when their resource is versioned, with paths such as mutate.0.relations.products.1.
  • With a list of keys, every row of the list has to hold that version. Each key whose version differs gives one conflict, under the path of the entry:
409 Conflict
{
  "message": "The record was changed by another request.",
  "conflicts": [
    {"path": "mutate.0.relations.products.0", "key": 2, "version": "2026-01-02T08:00:00.000000Z"}
  ]
}

When the check runs

  • In the transaction of the mutation, after the row checks and before mutatingAll(). A row that a policy refuses answers 403 first.
  • Before a confirmation: a conflict answers 409, and the resource asks for no confirmation.
  • One SELECT ... FOR UPDATE per resource reads the rows again, so that no other request changes them before the commit.
  • With transactions.attempts above 1, each attempt runs the check again, see Deadlock retries.

Rules

  • version on a resource that is not versioned gives The mutate.0.version field is prohibited.
  • version on an operation other than update gives The mutate.0.version field is prohibited when mutate.0.operation is create., or ... when mutate.0.relations.products.0.operation is attach. under a relation.
  • version must be a string: The mutate.0.version field must be a string., null included.
  • destroy, restore and forceDestroy take no version.
  • versioned() on a model without timestamps throws InvalidResourceException: The resource [App\Rest\Resources\ProductResource] returns true from versioned(), but its model does not use timestamps. The version of a row is its updated_at column: turn the timestamps of the model on, or return false from versioned().
  • versioned() without the updated_at field throws InvalidResourceException: The resource [App\Rest\Resources\ProductResource] returns true from versioned(), but does not declare the field [updated_at]. The version of a row is this column as the field returns it: declare it, for example DateTime::make('updated_at')->readonly().
Store the timestamps with microseconds: $table->timestamps(precision: 6). With seconds, two saves in the same second give the same version, and the second one is not detected.

OpenAPI

The update entries of a versioned resource document version, typed like its updated_at field. A mutate operation that writes a versioned resource, at its root or under its relations, documents the 409 response with the shared RestConflict component. See OpenAPI.