Actions and Metrics

Actions

Run your own code on the rows of a resource, with typed inputs, row checks, transactions and a result for the client.

An action is a class that runs your code on rows of a resource: close tickets, send a notification, export orders. Each action gets its own route, validates typed inputs, checks the policies of the rows and answers with the number of rows it ran on and an optional result.

Defining an action

Generate an action with the rest:action Artisan command. The class is written in app/Rest/Actions:

php artisan rest:action ExtendSubscriptions
namespace App\Rest\Actions;

use Illuminate\Support\Collection;
use Lomkit\Rest\Actions\Action;
use Lomkit\Rest\Actions\ActionFields;
use Lomkit\Rest\Fields\Date;
use Lomkit\Rest\Fields\Enum;

class ExtendSubscriptions extends Action
{
    public function fields(): array
    {
        return [
            Date::make('expires_at')->rules('required'),
            Enum::make('plan', Plan::class)->default(Plan::Monthly),
        ];
    }

    public function handle(ActionFields $fields, Collection $models): void
    {
        foreach ($models as $subscription) {
            $subscription->update(['expires_at' => $fields->expires_at, 'plan' => $fields->plan]);
        }
    }
}

Declare it in the actions() method of the resource:

use App\Rest\Actions\ExtendSubscriptions;

public function actions(): array
{
    return [
        ExtendSubscriptions::make(),
    ];
}
  • The action is named after its class, without an Action suffix: ExtendSubscriptions gives the name Extend Subscriptions and the key extend-subscriptions. Set the $name property to choose another name.
  • Rest::resource() reads actions() when it registers the routes, without a user. Keep the list the same for every user and free of queries, and run php artisan route:cache again after you add an action.
  • ->meta(['color' => '#FFFFFF']) adds keys that the details give to clients with ?with=actions.meta. getMeta() reads them.
  • Inside an action, $this->resource is the resource and $this->request() the request of the endpoint.

Running an action

Each action gets the route POST /{resource}/actions/{uriKey}, named {name}.operate.{uriKey}:

POST /api/subscriptions/actions/extend-subscriptions
{
  "fields": [{"name": "expires_at", "value": "2027-01-31"}],
  "search": {"filters": [{"field": "status", "value": "active"}]}
}
{"data": {"impacted": 11, "result": null}}
  • fields is a list of {name, value} entries, not an object keyed by name. An object is a 422: The fields field must be a list. Each name appears once, and an entry takes name and value only.
  • impacted is the number of rows that the action ran on: the rows given to handle(), or the rows put into the jobs of a queued action.
  • An unknown action answers 404 to a user allowed viewAny on the resource, and 403 to the others.

Targets

The target of an action sets the rows it runs on, and what the request may send. Set it with the $target property, or when the resource declares the action:

use Lomkit\Rest\Actions\ActionTarget;

class DeactivateUsers extends Action
{
    public ActionTarget $target = ActionTarget::Resources;
}
public function actions(): array
{
    return [SendDigest::make()->target(ActionTarget::None)];
}
TargetsearchresourcesRows
ActionTarget::Search, the defaultrequiredrefusedthe rows that search matches inside the perimeter, every row with "search": {}
ActionTarget::Resourcesrefusedrequiredthe rows of the keys in resources, in their order
ActionTarget::Nonerefusedrefusednone: handle() receives an empty collection
With the Search target, send "search": {} to run the action on every row of the perimeter. A body without search is a 422: The search field is required. Use ActionTarget::Resources when an action must never reach every row: the client then names each row by key.
{"resources": [1, 5, 9], "fields": [{"name": "reason", "value": "spam"}]}
  • search takes the keys of a search, without page, as an object. text works on a resource that declares a text search.
  • A key of resources that does not exist, or that perimeterQuery() leaves out, is a 422 under resources.{index}. A repeated key is a 422 too. A key outside the perimeter also dispatches the KeysOutOfReach event.
  • The details, with ?with=actions, give the target of each action as "target": "search", "resources" or "none".

Capping the rows

Every target is capped by the $maxResources property, 1000 by default. A search that matches more rows answers a 422 under search before the action runs on any row: This action can be run on at most 1000 resources; narrow the search.

public ?int $maxResources = 5000;

->maxResources(5000) sets it when the resource declares the action. null removes the cap: the action then runs on every row that its search matches, without counting them first.

Fields

fields() returns typed fields, the same classes as the fields of a resource. They take rules(), nullable(), default(), label() and messages().

  • Each field is validated as a normal Laravel validation: required, required_if and present fire even when the field is absent from fields.
  • An error is keyed by the field name, fields.expires_at. An unknown or repeated name is reported by position, fields.0.name.
  • An Enum field names its enum class, Enum::make('plan', Plan::class), because action fields are not bound to a model.
  • The list takes at most rest.limits.sizes.fields entries, 100 by default.

handle() receives a read-only Lomkit\Rest\Actions\ActionFields. Each declared input is a property cast to its field type, with its default filled in:

ReadGives
$fields->expires_ata CarbonImmutable for a Date, the case for an Enum, null when not sent and without default
$fields->has('reason')true when the client sent it, null included, or when the field has a default
$fields->filled('reason')has() and not null, like isset($fields->reason)
$fields->only('plan', 'reason'), $fields->toArray()the inputs that has() reports, by name
$fields->user()the user who sent the request, also in a queued job
$fields->target()the ActionTarget of the action

Reading an input that fields() does not declare throws Lomkit\Rest\Exceptions\InvalidFieldException.

Results

Override respond() to answer the client with a result. It runs once, after the last chunk, and receives the fields and impacted:

use Lomkit\Rest\Actions\ActionFields;
use Lomkit\Rest\Actions\ActionResponse;

public function respond(ActionFields $fields, int $impacted): ?ActionResponse
{
    return ActionResponse::message("{$impacted} subscriptions extended");
}
{"data": {"impacted": 11, "result": {"type": "message", "message": "11 subscriptions extended"}}}
respond() returnsresult
ActionResponse::message('Done'){"type": "message", "message": "Done"}
ActionResponse::danger('2 subscriptions are locked'){"type": "danger", "message": "2 subscriptions are locked"}
ActionResponse::download($url, 'subscriptions.csv'){"type": "download", "url": "...", "name": "subscriptions.csv"}
ActionResponse::redirect($url){"type": "redirect", "url": "..."}
ActionResponse::created($invoices){"type": "created", "keys": [29, 30]}
ActionResponse::data(['export' => 41, 'rows' => 120]){"type": "data", "data": {"export": 41, "rows": 120}}
ActionResponse::message('Export started')->withData(['export' => 41]){"type": "message", "message": "Export started", "data": {"export": 41}}
null, the defaultnull
  • The status stays 200. The package does not send the file or follow the URL: the client acts on the result.
  • data() and withData() take an array with string keys. A list throws Lomkit\Rest\Exceptions\InvalidActionException.
  • respond() does not run when handle() throws.

Hints for clients

->destructive() and ->confirm('Extend the selected subscriptions?') mark an action for clients, or the $destructive and $confirm properties. The details give both on each action, with ?with=actions, and the OpenAPI operation of the action carries them as x-lomkit-destructive and x-lomkit-confirm. The endpoint runs the action the same way: to make the endpoint check a confirmation, see Confirmation.

Abilities

Before validation, a known action checks, in this order:

  1. viewAny on the resource;
  2. the abilities that the resource's abilities() returns for Endpoint::Operate, none by default;
  3. the abilities that the action's abilities() returns, ['viewAny'] by default.
public function abilities(): array
{
    return ['export'];
}

These abilities add to viewAny: the action above checks viewAny, then export. An action whose abilities() returns [] runs without any of these checks, for an action that any user may run, guests included. The resources that its search reaches through includes and relation filters still need viewAny. See Policies.

Checking each row

authorizeEach() names a policy ability that the action checks on every row before it runs on any row:

public function authorizeEach(): ?string
{
    return 'update';
}

A refused row answers 403 with the message of the first refusal, {"message": "Closed tickets are read-only."}, and no row is handled, no job is queued. To list every refused row in the body, see the denials of Policies.

With ->skipUnauthorized(), or public bool $skipUnauthorized = true;, the action runs on the allowed rows only, and impacted counts them. A queued action checks the rows in the request, not again in the worker.

  • skipUnauthorized() without an ability, or an ability on the None target, throws InvalidActionException when the actions of the resource are read.
  • php artisan rest:check reports each action on the Search or Resources target whose authorizeEach() returns null. An action that only reads returns 'view'.

Chunks and transactions

The rows reach handle() in chunks of $chunkCount rows, 100 by default.

  • A search without sorts and without limit reads the rows in the order of their key, chunk after chunk, so an action that changes a filtered column still reaches every row.
  • With sorts or a limit, the action reads the keys first, in the order of the search, then loads each chunk of keys.
  • The Resources target gives the rows in the order of resources.

The $transaction property chooses the transactions, on the connection of the model:

ValueDeclaringBehaviour
ActionTransaction::Chunk, the defaulteach chunk runs in its own transaction, also around handle() in a queued job
ActionTransaction::Whole->atomic()every chunk runs in one transaction: a throw rolls back every row
ActionTransaction::Row->transactionPerRow()each row runs in its own transaction, in handleRow(), see below
ActionTransaction::None->withoutTransaction()no transaction: each row is written when handle() writes it
use Lomkit\Rest\Actions\ActionTransaction;

public ActionTransaction $transaction = ActionTransaction::None;
  • Use None for an action that calls an external service on each row or commits its own progress.
  • respond() runs after the commit.
  • Whole on an action that implements ShouldQueue throws InvalidActionException: its chunks run in the worker, outside the request.
  • With transactions.attempts above 1 in config/rest.php, a chunk, a row or a whole run that hits a deadlock runs again. See Deadlock retries.

One transaction per row

With ActionTransaction::Row, each row runs in its own transaction, in the request and in the jobs of a queued action, whatever $chunkCount. The rows reach handleRow() instead of handle(). beforeRow() runs just before, outside any transaction, for a call to an external service:

use Illuminate\Database\Eloquent\Model;
use Lomkit\Rest\Actions\Action;
use Lomkit\Rest\Actions\ActionFields;
use Lomkit\Rest\Actions\ActionTransaction;

class InvoiceOrders extends Action
{
    public ActionTransaction $transaction = ActionTransaction::Row;

    public function beforeRow(ActionFields $fields, Model $row): void
    {
        $this->billing->reserveInvoiceNumber($row);
    }

    public function handleRow(ActionFields $fields, Model $row): void
    {
        $row->update(['status' => 'invoiced']);
    }
}
  • When a row throws, its transaction is rolled back and the run, or the job, stops there. The rows before it stay written.
  • A deadlock runs the transaction of the row again, handleRow() included, but not beforeRow().
  • An action with Row throws InvalidActionException when the actions of the resource are read if it does not declare handleRow(), if it declares handle(), or if its target is None. So does an action that declares handleRow() or beforeRow() without Row:
App\Rest\Actions\InvoiceOrders runs each row in its own transaction (ActionTransaction::Row), but it does not declare handleRow(): with Row, each row reaches handleRow(ActionFields $fields, Model $row) instead of handle().

Middleware

An action can carry its own route middleware, when the resource declares it or as properties:

public function actions(): array
{
    return [
        ExportTickets::make()->middleware('throttle:exports'),
        ReportOutage::make()->withoutMiddleware('auth:sanctum'),
    ];
}
class ExportTickets extends Action
{
    public array $middleware = ['throttle:exports'];
}

The route gets the package middleware, the middleware of the resource, the middleware of middlewareFor('operate', ...), then its own. withoutMiddleware() removes a middleware of any of these: name it as it is declared, with its parameters.

Reading the request

Once the request passed validation, $this->request()->payload() returns a Lomkit\Rest\Actions\OperationNode: action, fields (the input values as sent) and target (the parsed search, the keys of resources, or null). The resource's operating() and operated() events run around the chunks, see Events and hooks.