Skip to content

Intent Dispatch ​

An intent is a named thing a user wants to happen: open this app, open that dialog, log out, follow a link to a related object. The client names the intent and its parameters. The server decides whether the actor may have it, does the work, and answers with what the shell should do next.

That inversion is the point. The client holds no URL, no permission rule and no knowledge of what the intent does. It knows a name.

js
LaravelUi5.dispatchIntent({
    name: 'ui5-artifact.open',
    parameters: { namespace: 'com.acme.orders.dialogs.create' },
});

One route ​

POST /ui5/shell/{slug}/intend.json

{slug} is the artifact the user is in, which is how the request gets its SdkContext — the same middleware stack that resolves every UI5 request resolves this one. The client never builds the URL: the shell manifest hands it the template and it fills in the slug.

The body carries two keys:

json
{ "intent": "weave.open", "parameters": { "concept": "laravelui5.partner", "refKey": 42 } }

The parameters are spread into the intent's constructor as named arguments. The parameter names of your intent class are therefore part of its wire contract — renaming a constructor parameter breaks every client that sends it.

Authorize, then handle ​

Dispatch is two steps, in this order, with no way around them:

php
$authorizer = $this->registry->getIntentAuthorizer($name);
if (!$authorizer->authorize($intent, $context)) {
    return IntentResult::deny('Not authorized');
}
$handler = $this->registry->getIntentHandler($name);
return $handler->handle($intent, $context);

The handler is not even resolved until the authorizer has said yes. And there is no "no authorizer" case: registering an intent without both halves throws at build time. Open access has to be written down. The SDK's own logout intent spells it out — it accepts any authenticated actor, and its authorizer says so in one line.

What comes back ​

A handler returns an IntentResult: a type and a payload. The type is a closed vocabulary, and the shell has one behaviour per type.

TypePayloadThe shell
redirecturlfull page load to the URL
dialogapp, dialogViewopens the global dialog (Global Dialogs)
valueHelpapp, view, entitySetopens the picker — this one is returned to openValueHelp, not to the dispatch path
dispatchintentdispatches that intent in turn
messagelevel, textsee the caveat below
denyreasonsee the caveat below
opentarget, paramsnothing yet — no shipped handler returns it

A denial is currently silent

message and deny reach the browser but the shell only logs them to the console; the toast and the dialog are not wired up yet. If you write an intent whose refusal the user must see, return a dialog or handle the refusal in your own controller until this is closed. It is on the SDK roadmap.

Status codes ​

SituationStatusBody
the authorizer refused200{"type":"deny","payload":{"reason":"Not authorized"}}
the handler refused — unknown artifact, unsupported target200deny with its reason
no intent name400deny with Missing intent name
an unknown name, or parameters that don't fit the constructor400deny with Invalid intent request

A refusal is a 200 with a deny body, not a 403. Only a malformed request is a 4xx. Note that a 400 tells you nothing about which of the three causes it was; the exception is not passed on.

The intents the SDK ships ​

Seven, registered by the SDK itself. A bare host has exactly these.

NameParametersWhat it doesWho may
navigation.opentargetswitches to an app (app:<namespace>) or one of its routes (route:<namespace>:<pattern>)the target app's #[Access]
ui5-artifact.opennamespaceopens an artifact by namespace: an app redirects, a dialog opens in the shellthe artifact's own #[Access]
ui5-valuehelp.opennamespace, scope, contextopens a value help on one of its scopesthe picker's own #[Access]
weave.openconcept, refKeyjumps to a concept's own detail page (LUX Weave)the providing app's #[Access]
weave.navigateconcept, target, refKeyfollows an outbound link to a related appthe target app's #[Access]
partner.impersonatetargetPartnerIdstarts, switches or stops impersonation (Impersonation)the principal's delegation, never the actor's
session.logout—logs out, invalidates the session, lands on the named route homeany authenticated actor

Three of them share one rule, in one class: an app with no #[Access] ability is open to every signed-in partner; an app that declares one requires the grant. That is the same answer the navigation rail, the Weave menu and the Launchpad tile give, by construction rather than by convention.

session.logout lands on the route named home, and impersonation lands on the Launchpad. Both are names your host must be able to resolve.

Writing your own ​

Three classes and one config entry.

The intent — identity and parameters, nothing else. No work, no authorization, no URLs.

php
use LaravelUi5\Sdk\Intent\AbstractIntent;

final readonly class ClosePeriodIntent extends AbstractIntent
{
    public function __construct(public int $periodId) {}

    public static function name(): string
    {
        return 'period.close';
    }

    public function parameters(): array
    {
        return ['periodId' => $this->periodId];
    }
}

Extend AbstractIntent for the JSON shape, or implement IntentInterface directly. Name it <domain>.<verb>, and keep the name stable — it is what the client sends.

The authorizer — one method, returning a bool. Type-guard first, then ask the context. An ability is checked by its id: AbilityIndexInterface turns a declaration into the id ui5:sync assigned it, and the actor's ability set answers whether they hold it.

php
final class ClosePeriodIntentAuthorizer implements IntentAuthorizerInterface
{
    public function __construct(private AbilityIndexInterface $index) {}

    public function authorize(IntentInterface $intent, SdkContext $context): bool
    {
        if (!$intent instanceof ClosePeriodIntent) {
            return false;
        }

        $id = $this->index->abilityId('com.acme.accounting', AbilityType::Act, 'closePeriod');

        return $id !== null && $context->abilities()->allowsAbilityId($id);
    }
}

The namespace is the module's, not the artifact's: abilities are catalogued per module. A null id means the ability was never synced — decide deliberately whether that is open or closed. The shipped authorizers differ: ui5-artifact.open and ui5-valuehelp.open refuse an unresolvable id, while navigation.open, weave.open and weave.navigate go through AppAccessPolicy, which lets a null id pass.

The handler — does the work and returns an IntentResult. It does not build UI, does not mutate the intent, and does not authorize a second time.

php
final class ClosePeriodIntentHandler implements IntentHandlerInterface
{
    public function handle(IntentInterface $intent, SdkContext $context): IntentResult
    {
        if (!$intent instanceof ClosePeriodIntent) {
            return IntentResult::deny('Wrong intent');
        }

        // … do the work …

        return IntentResult::redirect(route('periods.show', $intent->periodId));
    }
}

Register the triple in your config/ui5.php:

php
'intents' => [
    ClosePeriodIntent::class => [
        'authorizer' => ClosePeriodIntentAuthorizer::class,
        'handler'    => ClosePeriodIntentHandler::class,
    ],
],

Both keys are required. The registry keys your intent by the name name() returns, and resolves the handler and the authorizer from the container, so they may take constructor dependencies.

Then dispatch it from your app:

js
LaravelUi5.dispatchIntent({ name: 'period.close', parameters: { periodId: 4711 } });

What it doesn't do ​

  • It is not a job queue. A handler runs inside the request and its answer steers the UI. Long work belongs in a queued job that the handler dispatches.
  • It has no presentation filter. An authorizer answers one question — may this actor run this intent. What a shell surface shows is decided elsewhere: the rail and the command palette project against the actor's abilities (Global Search).
  • It is not the write path for data. A mutation with a form, validation and a transaction is a Ui5Action. Intents move the user; actions change rows.

See also ​