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 naming OffersModule::getActions() and the literal line to add.

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 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:

MethodPurpose
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

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 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 ApproveHandler extends AbstractConfigurable implements ActionHandlerInterface

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

php
public function getActions(): array
{
    return [
        new Actions\ApproveAction($this),
    ];
}

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

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.

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:

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.