Skip to content

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 GET and nothing else — no POST, no PATCH, no DELETE, no deep inserts, no $batch changesets. Every create, update and delete in your application is a Ui5Action.

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, $filter never 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 for

The 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 ​

  1. The frontend calls LaravelUi5.call('com.acme.offers.actions.approve', …).
  2. The URL and method are read from the app's manifest — the client never hard-codes either.
  3. ActionDispatchController resolves the artifact from the route and takes its handler class-string.
  4. ExecutableInvoker resolves #[Parameter] route segments, injects #[Setting] values, validates any FormRequest in the signature, and calls handle().
  5. 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 ​

bash
php artisan ui5:action Offers/Approve --method=POST

This creates an action called Approve inside the existing Offers app module.

Options ​

OptionDefaultDescription
name (arg)(required)Format: {App}/{Action} — e.g. Offers/Approve
--methodPOSTHTTP 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.

⚠️ GET is not a valid action method. Actions mutate; a GET action would be a lie to every cache and crawler between the browser and Laravel. Declaring one throws InvalidHttpMethodActionException when the manifest is built. If you want a read endpoint, you want an OData entity set or a Ui5Resource.

Output ​

Given Offers/Approve:

plaintext
ui5/
└── Offers/
    └── src/
        └── Actions/
            ├── ApproveAction.php        ← the declaration
            └── Handler/
                └── ApproveHandler.php   ← the behaviour

On success the command prints the registration directive: the line to add to OffersModule::ARTIFACTS.

The pair ​

ApproveAction.php — the declaration ​

php
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 string NAMESPACE   = 'com.acme.offers.actions.approve';
    public const string TITLE       = 'Approve Offer';
    public const string DESCRIPTION = 'Move an offer from draft to approved.';

    public const HttpMethod METHOD = HttpMethod::POST;
    public const string HANDLER    = ApproveHandler::class;
    public const string REQUEST    = ApproveRequest::class;
}

An action is declared entirely in typed constants: four for its identity, three for what it does. The base class surfaces them through the getters Ui5ActionInterface requires.

ConstantGetterPurpose
METHODgetMethod()the HTTP method — POST, PATCH, or DELETE. Required
HANDLERgetHandler()class-string of the handler. Required; the runtime owns resolution
REQUESTgetRequest()class-string of the body-validating FormRequest. Leave it out when the action has no body

A required constant left empty fails loudly, naming the constant, the first time the action is read.

changed in 5.0The verb, the handler and the request are constants (`METHOD`, `HANDLER`, `REQUEST`). An action that still overrides `getMethod()`, `getHandler()` or `getRequest()` keeps working.

REQUEST 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 ​

php
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 ​

KindGoes inWhy
Services, repositories, gatewaysthe constructorthe container autowires it fully — and it sidesteps the invoker's has()-only resolution of method parameters
The FormRequesthandle()'s signatureit exists only per-request; the invoker instantiates it and triggers authorize() + validation
#[Parameter] valueshandle()'s signatureroute-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:

php
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 action, beside its METHOD and HANDLER:

php
use LaravelUi5\Core\Ui5\Attributes\Parameter;
use LaravelUi5\Core\Parameters\Enums\ParameterType;

#[Parameter(name: 'offer', uriKey: 'offer', type: ParameterType::Model, model: Offer::class)]
class ApproveAction extends AbstractUi5Action

The attribute is repeatable, and declaration order is the positional order in the URL. The resolved value is injected into the handler's 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.

changed in 6.0`#[Parameter]` sits on the action. Until 6.0 it sat on the handler; the registry now refuses it there at boot. Moving it changes nothing in the URL.

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 the module's version (since 7.0). The server does not read it for an action — a POST busts no cache — but every address of the stack has the same form.

You never build this string by hand — see Calling it below.

Module integration ​

Actions are subordinate artifacts and must be registered explicitly. Name the class in the module's ARTIFACTS:

php
public const array ARTIFACTS = [
    Actions\ApproveAction::class,
];
changed in 4.0Actions used to be returned from `getActions()`, built by hand.

Registration is what publishes it into the app's manifest, keyed by namespace:

jsonc
"actions": {
  "com.acme.offers.actions.approve": {
    "method": "POST",
    "url": "/ui5/api/com/acme/offers/actions/[email protected]/{offer}"
  }
}

The {offer} segment appears because the action declares that #[Parameter] — the manifest is built by reflecting it, and the dispatcher binds the same attributes, so the URL template and the handler signature cannot drift apart.

Calling it from UI5 ​

js
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.

The address is global ​

An action's namespace is its address, and it is global: any client can call any registered action, including one of another module. That is a feature. A global dialog opened from a foreign app saves through an action of its own module — the Partners create dialog, opened from the Launchpad, posts to com.laravelui5.partners.actions.create_person.

The facade finds such an action although the host app's manifest does not list it: while a global dialog is open, call() also looks in the manifest of the app that owns the dialog. since 3.0

What keeps an action from being called by the wrong person is therefore authorization, never the address — see below.

Authorization ​

Core is auth-blind: it will happily dispatch any registered action to any request that reaches it — a client that knows the namespace needs no manifest to build the URL — and leaves the question of who may to the host. Gate every action.

On an SDK host, an action carries its gate as an attribute on the declaration:

php
#[Act('approveOffer', SdkRole::LocalAdmin, note: 'Approve a customer offer.')]
class ApproveAction extends AbstractUi5Action

On 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 than UpdateOfferStatus.
  • One action, one intent. Resist the general-purpose Update action with a mode flag; you lose the authorization gate, the validation shape, and the readable call site all at once.
  • POST to create, PATCH to modify, DELETE to 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 message string in your array is surfaced automatically as a MessageToast by LaravelUi5.call — so the house shape is not a convention you have to honour by hand, it is wired. Give it something better than true.
  • 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 BusinessTransaction seal for exactly this.