Mutating Actions
In LaravelUi5, OData is read-only. Every change to your data goes through a Ui5Action.
Reads flow through OData entity sets; every create, update, and delete flows through a named, versioned, authorization-gated action. There is no such thing as an OData write in this framework — and that is deliberate. A mutation is not a side effect of a query; it is a first-class artifact with its own identity, its own permission, its own validation contract, and its own audit surface.
An action is:
POST/PATCH/DELETEonly — neverGETorPUT,- reached at the
api/route prefix (distinct from the OData read surface), - gated by an ability you declare, enforced server-side,
- wrapped in a database transaction the framework opens and commits (or rolls back) for you.
The shape: one command, three files
You never hand-write an action. Scaffold it:
php artisan ui5:action Partners/CreateGroup --method=POSTThat produces two files under ui5/Partners/src/Actions/, and you add a third:
| File | Role |
|---|---|
CreateGroupAction.php | The artifact — identity, HTTP method, and the wiring to its handler and request. |
Handler/CreateGroupHandler.php | The behaviour — the state-changing logic, and nothing else. |
CreateGroupRequest.php | The contract — validates the request body and carries the authorization gate. |
This separation is the whole design: the action says what it is, the request says who may call it and what a valid body looks like, and the handler changes state — reached only after identity, authorization, and validation have already passed.
1. The Action — identity + wiring
The action carries a stable namespace and version, its HTTP method, and pointers to its handler and request classes:
#[Act('createGroup', SdkRole::TenantAdmin, note: 'Create a security group definition.')]
class CreateGroupAction extends AbstractUi5Action
{
public const NAMESPACE = 'com.laravelui5.partners.actions.create_group';
public const VERSION = '1.0.0';
public const TITLE = 'Create Group';
public function getMethod(): HttpMethod { return HttpMethod::POST; }
public function getHandler(): string { return CreateGroupHandler::class; }
public function getRequest(): ?string { return CreateGroupRequest::class; }
}The #[Act] attribute is the permission (see Securing an action). The getRequest() pointer is not optional plumbing — it is the gate through which authorization and validation run.
2. The Handler — sealing one outcome
The handler implements a single, fixed method. It reads its inputs from the request context, changes state, and seals exactly one outcome on a business transaction:
#[Parameter(name: 'group', uriKey: 'group', type: ParameterType::Model, model: Group::class)]
class UpdateGroupHandler implements SdkActionHandlerInterface
{
public function handle(
BusinessTransaction $transaction,
BusinessContextInterface $context,
SdkContext $sdk,
): ActionResponse {
/** @var Group $group */
$group = $context->parameter('group'); // a resolved route identifier
$data = $context->validated(); // the validated request body
$group->update(['description' => $data['description']]);
$transaction->touched('/Groups'); // tell the client to refresh this list
return $transaction->commit('Group updated.');
}
}Read the three arguments as a sentence — what I seal · what I was given · who I am:
$transaction— the outcome you seal (below). The framework has already opened a DB transaction; yourcommitpersists it, yourrollbackundoes it.$context— the per-request inputs:->validated()(the request body),->parameter('name')(a route identifier) and->slot('name')/->slots()(resolved slot values).$sdk— who is acting:->actor()(the acting partner),->abilities(),->setting('key').
Anything the handler needs — repositories, services — goes in its constructor; the handler is resolved from the container, so its dependencies autowire.
Sealing the transaction
A handler must seal exactly one outcome and return it:
| Call | Meaning |
|---|---|
$transaction->commit($message, $data = []) | Success. $message becomes a toast on the client; $data is the payload (e.g. a new record's id). |
$transaction->rollback(...) | A business failure — undoes the transaction and returns a typed error. |
$transaction->touched('/EntitySet') | Marks OData paths the client should refresh after the commit. |
$transaction->info(new MessageText('messages.saved')) | Attaches a business message (its text is an i18n key). warning(), error() and success() attach the other severities. |
Returning without sealing, or returning something else, is a programming error the framework rejects. A thrown exception rolls the transaction back and returns a technical failure. You never manage the database transaction yourself — you declare the outcome, the framework commits or unwinds.
3. The FormRequest — validation and the gate
The request extends AbstractSdkFormRequest, which supplies both the authorization check (from the action's #[Act]) and the JSON error envelope. You define the body rules:
class CreateGroupRequest extends AbstractSdkFormRequest
{
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:255', 'unique:sdk_groups,code'],
'description' => ['required', 'string', 'max:2048'],
];
}
}The request is the gate
The #[Act] ability is checked by the middleware before the controller runs (→ 403). Resolving the FormRequest then fires its own authorize() (→ 403) and validation (→ a 422) before the database transaction opens. A null request skips that second step, the FormRequest's authorization and validation, not the #[Act] gate.
Route identifiers are models, not body fields
An action addresses which record it acts on through a route identifier, declared with #[Parameter] on the handler:
#[Parameter(name: 'group', uriKey: 'group', type: ParameterType::Model, model: Group::class)]Route identifiers are always Eloquent models — they are route-model-bound and read in the handler via $context->parameter('group'). Everything scalar — ids in a body, flags, values — belongs in the request body, never in a #[Parameter]. This keeps the URL a stable address of objects, and the body the carrier of data.
Securing an action
Security is opt-in, and an ungated action is an open door. Declare an #[Act] on the action:
#[Act('createGroup', SdkRole::TenantAdmin, note: 'Create a security group definition.')]- The ability name is short and camelCase (
createGroup); the action already scopes it. - The role is the
SdkRolethe ability belongs to (LocalAdmin,TenantAdmin, …). - The gate is inert until you run
php artisan ui5:sync— the resolver reads the synced database, not live attributes. Before sync the request fails with a missing-module or missing-ability error, it is not silently open; after sync, a caller without the ability receives a403. - Provide the ability's i18n labels (
abilities.act.createGroup.title/.description). Renaming an ability is a breaking security change.
The response envelope
Every action resolves to one of two byte-compatible shapes, so the client has a single parse path:
// success
{ "status": "Success", "message": "Group updated.", "data": null, "messages": [], "sideEffects": [] }
// failure: a business rollback fills messages; a validation 422 fills errors
{ "status": "Error", "message": "…", "errors": {}, "messages": [] }The success message surfaces as a toast; sideEffects carries the touched refresh hints; messages carries business messages (resolved from i18n keys on the client).
Calling an action from the frontend
From a UI5 controller, call through the @laravelui5/core facade:
LaravelUi5.call(
"com.laravelui5.partners.actions.update_group", // the action's NAMESPACE
{ group: id }, // fills the {group} route identifier
{ description } // the JSON body → the FormRequest
);The params fill the route-identifier placeholders; the body is the JSON payload (a DELETE may carry one too). An optional fourth argument, processor, names the form model that receives 422 field errors, so bound controls show their error state. The resolved response carries the commit message, the refresh hints, and any business messages. There is nothing to register on the client — each action is injected into the served manifest.json automatically, and a call to a name the manifest doesn't know throws.
At a glance
- OData reads; actions write —
POST/PATCH/DELETE, scaffolded withui5:action. - The action declares identity + method; the request validates + gates; the handler changes state and seals one outcome.
- Inputs come off
$context(validated(),parameter()); the actor off$sdk; dependencies via the constructor. - The
#[Act]gate fires in the middleware; the FormRequest adds its own authorization and validation. - Route identifiers are models; scalars go in the body.
- Run
ui5:syncto activate the security gate; call from the client withLaravelUi5.call.
See also
- Scoped Entity Sets — the read side that actions complement