Actions
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
Actionsuffix:ExtendSubscriptionsgives the nameExtend Subscriptionsand the keyextend-subscriptions. Set the$nameproperty to choose another name. Rest::resource()readsactions()when it registers the routes, without a user. Keep the list the same for every user and free of queries, and runphp artisan route:cacheagain 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->resourceis 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}}
fieldsis a list of{name, value}entries, not an object keyed by name. An object is a422:The fields field must be a list.Each name appears once, and an entry takesnameandvalueonly.impactedis the number of rows that the action ran on: the rows given tohandle(), or the rows put into the jobs of a queued action.- An unknown action answers
404to a user allowedviewAnyon the resource, and403to 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)];
}
| Target | search | resources | Rows |
|---|---|---|---|
ActionTarget::Search, the default | required | refused | the rows that search matches inside the perimeter, every row with "search": {} |
ActionTarget::Resources | refused | required | the rows of the keys in resources, in their order |
ActionTarget::None | refused | refused | none: handle() receives an empty collection |
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"}]}
searchtakes the keys of a search, withoutpage, as an object.textworks on a resource that declares a text search.- A key of
resourcesthat does not exist, or thatperimeterQuery()leaves out, is a422underresources.{index}. A repeated key is a422too. A key outside the perimeter also dispatches theKeysOutOfReachevent. - 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_ifandpresentfire even when the field is absent fromfields. - An error is keyed by the field name,
fields.expires_at. An unknown or repeated name is reported by position,fields.0.name. - An
Enumfield 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.fieldsentries,100by 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:
| Read | Gives |
|---|---|
$fields->expires_at | a 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() returns | result |
|---|---|
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 default | null |
- The status stays
200. The package does not send the file or follow the URL: the client acts on the result. data()andwithData()take an array with string keys. A list throwsLomkit\Rest\Exceptions\InvalidActionException.respond()does not run whenhandle()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:
viewAnyon the resource;- the abilities that the resource's
abilities()returns forEndpoint::Operate, none by default; - 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 theNonetarget, throwsInvalidActionExceptionwhen the actions of the resource are read.php artisan rest:checkreports each action on theSearchorResourcestarget whoseauthorizeEach()returnsnull. 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
sortsand withoutlimitreads the rows in the order of their key, chunk after chunk, so an action that changes a filtered column still reaches every row. - With
sortsor alimit, the action reads the keys first, in the order of the search, then loads each chunk of keys. - The
Resourcestarget gives the rows in the order ofresources.
The $transaction property chooses the transactions, on the connection of the model:
| Value | Declaring | Behaviour |
|---|---|---|
ActionTransaction::Chunk, the default | each 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
Nonefor an action that calls an external service on each row or commits its own progress. respond()runs after the commit.Wholeon an action that implementsShouldQueuethrowsInvalidActionException: its chunks run in the worker, outside the request.- With
transactions.attemptsabove1inconfig/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 notbeforeRow(). - An action with
RowthrowsInvalidActionExceptionwhen the actions of the resource are read if it does not declarehandleRow(), if it declareshandle(), or if its target isNone. So does an action that declareshandleRow()orbeforeRow()withoutRow:
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.