Ui5Action
Introduction
A Ui5Action is a state-changing backend operation exposed to your UI5 frontend — approve an invoice, toggle a lock, trigger a workflow, delete a draft.
It is also the only way a LaravelUi5 app writes anything.
⚠️ OData in LaravelUi5 is read-only. The OData surface serves
GETand nothing else — noPOST, noPATCH, noDELETE, no deep inserts, no$batchchangesets. Every create, update and delete in your application is aUi5Action.This is a deliberate split, not a missing feature. Reads want a query grammar; writes want a named operation with validation, authorization, and a transaction boundary. Conflating them is what makes OData write stacks painful. Here,
$filternever has to mean anything to a write, and a write never has to be expressible as an entity mutation.
An Action is HTTP-addressable, versioned, registered in the artifact registry like any other artifact, and callable from the frontend with one line.
Conceptual overview
An action is a pair: a declaration and a behaviour.
ToggleLockAction ToggleLockHandler
──────────────── ─────────────────
what it is: what it does:
namespace, version handle(): array
HTTP method receives the FormRequest,
which handler the #[Parameter] models,
which FormRequest and any service it asks forThe split is the same one every artifact type makes: the artifact is a POPO that declares, and a separate class does. It means the handler is a plain, testable object with no framework surface, and it means the declaration can be read by the registry — to route it, to publish it into the manifest, and (on an SDK host) to gate it — without ever instantiating the behaviour.
Lifecycle of a call
- The frontend calls
LaravelUi5.call('com.acme.offers.actions.approve', …). - The URL and method are read from the app's manifest — the client never hard-codes either.
ActionDispatchControllerresolves the artifact from the route and takes its handler class-string.ExecutableInvokerresolves#[Parameter]route segments, injects#[Setting]values, validates anyFormRequestin the signature, and callshandle().- The returned array is serialised as JSON.
Every failure in steps 3–4 is loud and immediate: a bad path segment, an unresolvable argument, or a failed validation surfaces before your code runs.
How to generate
php artisan ui5:action Offers/Approve --method=POSTThis creates an action called Approve inside the existing Offers app module.
Options
| Option | Default | Description |
|---|---|---|
name (arg) | (required) | Format: {App}/{Action} — e.g. Offers/Approve |
--method | POST | HTTP method — only POST, PATCH, DELETE are valid |
That is the whole signature. The generator finds the module at ui5/{App}/src/{App}Module.php and reads its namespace prefixes from there, so there is nothing else to pass.
⚠️
GETis not a valid action method. Actions mutate; aGETaction would be a lie to every cache and crawler between the browser and Laravel. Declaring one throwsInvalidHttpMethodActionExceptionwhen the manifest is built. If you want a read endpoint, you want an OData entity set or aUi5Resource.
Output
Given Offers/Approve:
ui5/
└── Offers/
└── src/
└── Actions/
├── ApproveAction.php ← the declaration
└── Handler/
└── ApproveHandler.php ← the behaviourOn success the command prints the registration directive naming OffersModule::getActions() and the literal line to add.
The pair
ApproveAction.php — the declaration
namespace Acme\Offers\Actions;
use LaravelUi5\Core\Ui5\AbstractUi5Action;
use LaravelUi5\Core\Ui5\Enums\HttpMethod;
use Acme\Offers\Actions\Handler\ApproveHandler;
use Acme\Offers\Actions\Requests\ApproveRequest;
class ApproveAction extends AbstractUi5Action
{
public const NAMESPACE = 'com.acme.offers.actions.approve';
public const VERSION = '1.0.0';
public const TITLE = 'Approve Offer';
public const DESCRIPTION = 'Move an offer from draft to approved.';
public function getMethod(): HttpMethod
{
return HttpMethod::POST;
}
public function getHandler(): string
{
return ApproveHandler::class;
}
public function getRequest(): ?string
{
return ApproveRequest::class;
}
}Identity is four constants, surfaced as getters by the HasArtifactIdentity trait. Ui5ActionInterface adds exactly three methods:
| Method | Purpose |
|---|---|
getMethod() | the HTTP method — POST, PATCH, or DELETE |
getHandler() | class-string of the handler. Return the string, never app(...) — the runtime owns resolution |
getRequest() | class-string of the body-validating FormRequest, or null (the base default) |
getRequest() is worth a note, because it looks redundant with the handler's own signature. In Core's dispatch it is optional: a handler that type-hints a FormRequest parameter gets it resolved and validated by the invoker through plain method injection, and needs no declaration. The seam exists for a dispatcher whose handler signature is fixed and therefore exposes no FormRequest parameter to reflect — there the declared class-string is the only way to discover the body validator. Declare it when you have one; it costs nothing and it documents the action.
ApproveHandler.php — the behaviour
namespace Acme\Offers\Actions\Handler;
use LaravelUi5\Core\Ui5\AbstractConfigurable;
use LaravelUi5\Core\Ui5\Capabilities\ActionHandlerInterface;
class ApproveHandler extends AbstractConfigurable implements ActionHandlerInterface
{
public function __construct(private OfferApprover $approver) {}
public function handle(Offer $offer, ApproveRequest $request): array
{
$this->approver->approve($offer, $request->validated('note'));
return [
'status' => 'success',
'message' => "Offer {$offer->number} approved.",
];
}
}ActionHandlerInterface is a marker — it declares no method. That is deliberate: handle()'s parameters are resolved by the container at invoke time, which no PHP interface can express (the same reason Laravel's own ShouldQueue never types handle()). The contract is documented and enforced at runtime instead — a missing handle() throws MissingExecutableMethodException.
Always return a structured array, even when there is nothing to report. ['status' => …, 'message' => …] is the house shape; it gives the frontend something to show without inventing a convention per action.
Where each dependency goes
| Kind | Goes in | Why |
|---|---|---|
| Services, repositories, gateways | the constructor | the container autowires it fully — and it sidesteps the invoker's has()-only resolution of method parameters |
The FormRequest | handle()'s signature | it exists only per-request; the invoker instantiates it and triggers authorize() + validation |
#[Parameter] values | handle()'s signature | route-resolved, matched to the argument by name |
The constructor rule matters more than it looks. Method-parameter services are resolved with Container::has(), which is false for an interface with no explicit binding — so an interface dependency in handle() fails where the same dependency in __construct resolves cleanly.
ApproveRequest.php — the payload contract
A plain Laravel FormRequest. Nothing LaravelUi5-specific:
class ApproveRequest extends FormRequest
{
public function rules(): array
{
return ['note' => ['nullable', 'string', 'max:500']];
}
}The generator does not scaffold it — add one when the action takes a body.
Identifiers in the path, payload in the body
An action that operates on a specific record declares it with #[Parameter] on the handler:
use LaravelUi5\Core\Ui5\Attributes\Parameter;
use LaravelUi5\Core\Parameters\Enums\ParameterType;
#[Parameter(name: 'offer', uriKey: 'offer', type: ParameterType::Model, model: Offer::class)]
class ApproveHandler extends AbstractConfigurable implements ActionHandlerInterfaceThe attribute is repeatable, and declaration order is the positional order in the URL. The resolved value is injected into the handle() argument whose name matches — Offer $offer above — and is type-guarded against the signature, so a mismatch throws InvalidParameterTypeException rather than passing something unexpected into your logic.
Everything else belongs in the body. Identifiers go in the path; options, flags and payloads go through a FormRequest. That is plain Laravel convention, and keeping to it is what lets the URL stay a stable, cacheable, loggable identity for the operation.
Routing
Actions are routed like every other artifact — from the namespace and version, never from a hand-written path:
{POST|PATCH|DELETE} /ui5/api/{namespace-as-path}@{version}/{parameters…}So com.acme.offers.actions.approve at version 1.0.0, with one offer parameter, is reachable at:
POST /ui5/api/com/acme/offers/actions/[email protected]/{offer}Dots in the namespace become slashes in the URL. The api segment is the Action route prefix; the @{version} coordinate is part of the address, which is why bumping an artifact's VERSION is a breaking, cache-busting act.
You never build this string by hand — see Calling it below.
Module integration
Actions are subordinate artifacts and must be registered explicitly. Pass the module instance:
public function getActions(): array
{
return [
new Actions\ApproveAction($this),
];
}Registration is what publishes it into the app's manifest, keyed by namespace:
"actions": {
"com.acme.offers.actions.approve": {
"method": "POST",
"url": "/ui5/api/com/acme/offers/actions/[email protected]/{offer}"
}
}The {offer} segment appears because the handler declares that #[Parameter] — the manifest is built by reflecting it, so the URL template and the handler signature cannot drift apart.
Calling it from UI5
await LaravelUi5.call("com.acme.offers.actions.approve", { offer: 42 }, { note: "Looks good" });The facade reads the method and URL template from the manifest, fills the path parameters from the second argument, sends the third as the JSON body, and attaches the CSRF token. You address the action by namespace; the wire details stay server-owned.
Authorization
Core is auth-blind: it will happily dispatch any registered action to any request that reaches it, and leaves the question of who may to the host.
On an SDK host, an action carries its gate as an attribute on the declaration:
#[Act('approveOffer', SdkRole::LocalAdmin, note: 'Approve a customer offer.')]
class ApproveAction extends AbstractUi5ActionOn a Core-only host, gate it the ordinary Laravel way — in the FormRequest's authorize(), which the invoker calls before handle() runs.
Best practices
- Name the operation, not the mutation.
Approve,DiscardDraft,SyncProject— an action is a domain command, and it reads better in the manifest, in logs, and at the call site thanUpdateOfferStatus. - One action, one intent. Resist the general-purpose
Updateaction with a mode flag; you lose the authorization gate, the validation shape, and the readable call site all at once. POSTto create,PATCHto modify,DELETEto remove. Those are the three the framework allows, so use them as they read.- Keep the handler thin and free of framework plumbing — it is a plain object, so it stays unit-testable.
- Return a message the UI can show. A
messagestring in your array is surfaced automatically as aMessageToastbyLaravelUi5.call— so the house shape is not a convention you have to honour by hand, it is wired. Give it something better thantrue. - Wrap multi-step writes in a transaction yourself. Core does not open one for you — on an SDK host, the typed action contract supplies a
BusinessTransactionseal for exactly this.
Related Links
- Parameters — route-bound record identifiers
- Settings — configuration injected into the handler
- OData Service — the read side of the same app
- Ui5Resource — read endpoints OData does not serve
- Execution Model — the pipeline every invoked artifact shares
- Artifacts Overview