Actions and Metrics

Confirmation

Make the endpoint check that the user confirmed an action or a write before it runs, with a signed token and a 428 response.

->confirm() only asks clients to show a dialog: a client that skips it still runs the action. When an action or a write must not run unconfirmed, declare a confirmation hook. The endpoint then answers 428 with a signed token, and runs only when the same request comes back with that token.

  • Actions declare confirmation().
  • Resources declare mutateConfirmation(), destroyConfirmation(), restoreConfirmation() and forceDestroyConfirmation(), see Confirming writes.

Asking for a confirmation

confirmation() receives the fields and the number of rows the action would run on. Return a Confirmation to ask, or null to run at once:

use Lomkit\Rest\Actions\ActionFields;
use Lomkit\Rest\Confirmation\Confirmation;

class ShredDocuments extends Action
{
    public function confirmation(ActionFields $fields, int $rows): ?Confirmation
    {
        if ($rows < 3) {
            return null;
        }

        return Confirmation::make("Shred {$rows} documents?")
            ->data(['rows' => $rows])
            ->expiresIn(300);
    }
}
  • make() takes the message of the response.
  • data() adds an object for the client. It takes an array with string keys, {} when empty. A list throws Lomkit\Rest\Exceptions\InvalidConfirmationException.
  • expiresIn() sets the lifetime of the token in seconds, 300 by default, at least 1.
  • $rows counts the rows that the search matches in the perimeter, within its limit, the keys of the Resources target, or 0 for the None target. It is counted before skipUnauthorized() leaves rows out, so impacted can end lower.

The first request

POST /api/documents/actions/shred-documents
{"search": {}}

The endpoint validates the body, runs the entry checks and the row checks, then answers 428 and runs nothing:

{
  "message": "Shred 4 documents?",
  "confirmation": {
    "token": "1794132300.f70157796d502916907407d48c8d213b83d718f1c91d9723258bf7760aac3331",
    "expires_at": "2026-11-08T10:05:00Z",
    "data": {"rows": 4}
  }
}

No operating() event, no handle(), no job, no batch and no ActionOperated event run before the confirmation. A 422 and a 403 still come first.

Confirming

The client sends the same body again, with the token under confirmation:

{"search": {}, "confirmation": "1794132300.f70157796d502916907407d48c8d213b83d718f1c91d9723258bf7760aac3331"}
{"data": {"impacted": 4, "result": null}}

The token is valid for the same user and guard, the same resource and action, the same search, resources and fields, and the same number of rows, until it expires. It can be used again until then.

  • The order of the keys and the unknown keys at the first level of the body do not count.
  • Any other token, an expired one, or a number of rows that changed answers a new 428 with a fresh token. With one more row, the message becomes Shred 5 documents?.
  • confirmation must be a string: another type, null included, is a 422, The confirmation field must be a string.
The token holds no state: it is signed with app.key. Set APP_KEY before an action or a resource asks for a confirmation. An empty key throws Lomkit\Rest\Exceptions\InvalidConfigException once a confirmation is due. A hook that returns null needs no key.

Confirming writes

A resource asks for a confirmation before a write with one hook per endpoint. Each returns a Confirmation, or null, the default, to write at once:

HookEndpointReceives
mutateConfirmation(PendingMutation $mutation)mutatethe entries of the request
destroyConfirmation(Collection $models)destroythe rows of resources
restoreConfirmation(Collection $models)restorethe rows of resources
forceDestroyConfirmation(Collection $models)forceDestroythe rows of resources
use Illuminate\Database\Eloquent\Collection;
use Lomkit\Rest\Confirmation\Confirmation;

public function destroyConfirmation(Collection $models): ?Confirmation
{
    return Confirmation::make("Delete {$models->count()} documents?")
        ->data(['titles' => $models->pluck('title')->all()]);
}
DELETE /api/documents
{"resources": [1, 2]}
428 Precondition Required
{
  "message": "Delete 2 documents?",
  "confirmation": {
    "token": "1794132300.…",
    "expires_at": "2026-11-08T10:05:00Z",
    "data": {"titles": ["Budget", "Contract"]}
  }
}

The client sends the same body again with "confirmation": "<token>", and the endpoint writes.

Lomkit\Rest\Mutations\PendingMutation holds the entries of mutate, as MutationNode objects, and the stored rows that they name:

use Illuminate\Database\Eloquent\Model;
use Lomkit\Rest\Confirmation\Confirmation;
use Lomkit\Rest\Fields\MutationOperation;
use Lomkit\Rest\Mutations\MutationNode;
use Lomkit\Rest\Mutations\PendingMutation;

public function mutateConfirmation(PendingMutation $mutation): ?Confirmation
{
    $archived = collect($mutation->entries)
        ->filter(fn (MutationNode $entry): bool => $entry->operation === MutationOperation::Update)
        ->map(fn (MutationNode $entry): ?Model => $mutation->row($this, $entry->key))
        ->filter(fn (?Model $document): bool => (bool) $document?->archived);

    return $archived->isEmpty() ? null : Confirmation::make("Edit {$archived->count()} archived documents?");
}
  • $mutation->entries lists the entries of the root of mutate. Their relations hold the nested entries.
  • $mutation->row($resource, $key) returns the row of a key as stored before the request, or null.

When the checks run:

  • mutate asks after validation (422), the row checks (403) and the version check (409), inside its transaction, before mutatingAll().
  • destroy, restore and forceDestroy ask after validation and the row checks, before their transaction.
  • A 428 writes nothing: no mutatingAll(), destroyingAll(), restoringAll() or forceDestroyingAll(), no row hook and no event.

The token is valid for the same user and guard, the same resource and endpoint, and the same body: mutate and response on mutate, resources on the other endpoints. A token of forceDestroy does not confirm a destroy. The order of the keys of an object and the unknown keys at the first level of the body do not count, but the order of a list does: {"resources": [2, 1]} asks again.

The four endpoints read confirmation as a string, also on a resource without the hook: another type is a 422.

Clients

  • The details, with ?with=actions, give "confirmation": true on each action that declares confirmation(). With ?with=confirmations, they give the writes of the resource that ask for one: {"mutate": false, "destroy": true, "restore": false, "forceDestroy": false}. Show your dialog when the endpoint answers 428, with the message and the data of the response.
  • A Precognition request stops before the confirmation: it answers 204 or 422 and asks nothing.
  • The OpenAPI document lists the 428 response, RestConfirmationRequired, on the operations of these actions and writes, and documents confirmation in their body. See OpenAPI.