RFC 0010 — Laravel's abilities on the desktop: what the signed-in user may do
Status: implemented (2026-10-21) · Target: 0.18.0 · the guide: docs/backend.md · builds on RFC 0005, RFC 0006 and RFC 0007 · see Decisions
Summary
Who may delete a customer is decided by Laravel: a policy, a Gate, or
spatie/laravel-permission's roles. The desktop app doesn't know. Its
abilities (#[command(can = "customers.delete")]) are granted once, in code,
to every user alike, so a clerk sees a Delete button that the server answers
with 403, and, offline, a delete is queued that the server will refuse
tomorrow. The desktop should know what this user may do, ask nobody but
Laravel, and keep knowing while offline.
App::new()
.allow_abilities(resources::abilities()) // what the app's commands may need
.backend(
Backend::new("https://crm.example.com")
.abilities() // …and what this user has, from Laravel
.offline(),
)
// routes/api.php (elyracode/elyra-backend)
Route::middleware('auth:sanctum')->group(function () {
Route::elyraAbilities(); // GET /api/abilities: ["customers.view", "customers.create", …]
});
<script>
import { can } from "@elyra/runtime";
</script>
{#if $can("customers.delete")}
<button onclick={remove}>Delete</button>
{/if}
- From Laravel. After sign-in the app reads the user's abilities from
GET /api/abilities: spatie's permissions when the package is there, else the Gate abilities the app lists. Signing out forgets them. - Both must agree. A
can = ".."command runs when the app grants the ability (as today) and the user has it. A command the user can't run is refused at once, locally, without a request: online, offline, and before it would be queued. - The frontend knows. The
canstore answers$can("customers.delete"), and the screensmake:resourcegenerates hide what the user can't do. - It stays current. Read again on focus and on reconnect, and at once when Laravel broadcasts that the user's roles changed. Offline, the last list is used.
Motivation
- A button that always fails is a bug the user sees. The web half hides
actions with
@can; the desktop half should too, from the same rules. - Offline makes it worse. A write the user isn't allowed waits in the outbox and fails later, when they've moved on (RFC 0007). Knowing locally refuses it while they're still looking at the form.
- The rules are Laravel's, and should stay there. Copying roles into the desktop app would drift from the server. One list, from the server, per user.
- The pieces exist. Commands declare their ability; generated resources
list theirs in
ABILITIES;Authknows who's signed in; broadcasting reaches the user's own channel (RFC 0008); the outbox keeps a per-user copy of what the server said (RFC 0007).
Current state (0.17)
App::allow_ability(..)/allow_abilities(..)grant abilities to the webview once, at startup, for every user.Policy::grants_abilitychecks a fixed list;*wildcards are supported.- A command refused for a missing ability answers kind
forbidden(the IPC guard's kind). A403from Laravel is kinddenied. - The frontend can't ask what's granted: it learns by trying.
Authknows the signed-in user (/api/user) and tells watchers when it changes. The user's private channelApp.Models.User.{id}is followed when notifications are on.make:resourcegeneratesABILITIES(customers.view,.create,.update,.delete) and a hint to grant them; the views show every button.
Design
Turning it on
Backend::new(url)
.abilities() // GET /api/abilities
.abilities_route("/api/me/abilities") // elsewhere
Without .abilities(), nothing changes: the app's grants are all there is.
Where they come from
GET /api/abilities, as the signed-in user, answers a list of strings:
["customers.view", "customers.create", "invoices.*"]
*at the end covers a namespace, as inallow_ability.Route::elyraAbilities()inelyracode/elyra-backendanswers it. With spatie/laravel-permission installed it's the user's permissions ($user->getAllPermissions()->pluck('name'), roles included). Without it, it's every ability inconfig('elyra.abilities')thatGate::allows(..)for the user, so plainGate::defineand policy abilities work too.- Any other source works: it's one route that returns a list.
What may run
A command with can = "customers.delete" runs from the webview when:
- the app grants it (
allow_ability, as today): what the app lets its frontend reach at all; and - the signed-in user has it, by Laravel's list.
Signed out, or before the list has been read, the user has none: a gated
command is refused. Commands without can are unchanged. Rust callers
(tests, jobs, providers) aren't gated, as today.
A command refused because the user lacks the ability answers kind denied,
the kind a 403 from Laravel has, so the UI handles both the same way. The
app's own missing grant stays forbidden.
Per record
Laravel's policies decide per record too: Ada may update her customers, not
Bob's. That can't be a flat list. The convention Laravel apps already use is
a can object in the API Resource:
public function toArray($request): array
{
return [
...parent::toArray($request),
'can' => [
'update' => $request->user()->can('update', $this->resource),
'delete' => $request->user()->can('delete', $this->resource),
],
];
}
The generated views read record.can?.update when it's there and fall back
to the user's ability otherwise. The server still decides: a 403 is still
shown as it is today.
Keeping it current
- Read at sign-in, when a window gets focus, and when the connection comes back (RFC 0007).
- At once when Laravel broadcasts
AbilitiesChangedon the user's private channel, whichelyracode/elyra-backenddispatches when spatie's roles or permissions change for that user (and an app can dispatch itself). - A list that changed re-runs the live queries and tells the frontend's
canstore.
Offline
The last list is kept per user, with the offline copy (RFC 0007), and used while the server can't be reached. A write the user isn't allowed is refused before it's queued.
In Rust
let abilities = ctx.get::<Abilities>();
if abilities.allows("customers.export") { … }
abilities.list(); // what Laravel said, for this user
Abilities is in the container with .abilities(), and publishes its list
on elyra:abilities.
The frontend
import { can, abilities } from "@elyra/runtime";
$can("customers.delete") // true / false, kept current
$abilities // the user's list
can is a store of a function, as $t is. Before the list is read it
answers false.
make:resource --backend
- The Index, Show and Form views hide New, Edit and Delete when
$can("<plural>.<verb>")is false, and preferrecord.can.update/record.can.deletewhen the API sends them. - rata prints the Laravel side: the permissions to create (spatie) or the
Gate abilities to list in
config/elyra.php, andRoute::elyraAbilities().
Testing
let app = TestApp::new(app().swap(Http::fake(
HttpFake::new().get(url("/api/abilities"), 200, json!(["customers.view"])),
)));
app.sign_in(..).await;
assert_eq!(app.invoke_err_kind("customers_destroy", (1,)).await.as_deref(), Some("denied"));
TestApp has as_user_with_abilities(&[..]) for tests that don't go
through sign-in.
Security
- The server still decides. This is the desktop agreeing with Laravel,
not replacing it: every request is authorized by Laravel as before, and a
403is handled as before. - The list can only narrow. It never grants what the app didn't
(
allow_ability), so a server, or a stale list, can't open a command the app keeps from its webview. - The page can't change it. The list comes from Laravel through Rust; the frontend can read it, not set it.
- Per user. The kept list is the signed-in user's, and it's forgotten on sign-out and when someone else signs in.
Alternatives considered
- Abilities inside
/api/user. One request fewer, but it mixes the user's profile with what they may do, and/api/useris often an API Resource the app shapes for the web. A dedicated route is easy to cache and to change. - Ask Laravel per action (
GET /api/can?ability=..). Exact, but a request per button, and nothing offline. - Roles, not abilities. The desktop would need to know what each role allows: the rules would live in two places.
- Hide only; don't gate commands. Less to build, but a hidden button isn't a boundary, and offline writes would still be queued to fail.
Implementation plan
Abilities..abilities()/.abilities_route(..), the read at sign-in, forgetting at sign-out,allows/list, the kept copy per user offline, andelyra:abilities.- The gate. A
cancommand runs only when the app grants it and the user has it; kinddeniedotherwise; refused before queueing offline. - Keeping it current. Focus, reconnect,
AbilitiesChangedon the user's channel, and the live queries re-run. - The frontend.
/__auth/abilities, thecanandabilitiesstores. - Laravel and rata.
Route::elyraAbilities()andAbilitiesChangedinelyracode/elyra-backend;make:resource --backendviews that hide what the user can't do and readrecord.can; the Laravel side printed; CI against the real Laravel app with spatie's permissions; the docs.
As built, a few details differ from the text above:
- The gate is the shell's, at the IPC edge.
TestApp::invokedispatches as Rust does and isn't gated, so a test of the gate goes throughTestShell; there's noas_user_with_abilities. MCP calls aren't gated by the user's list either: an agent's grants are the app's. /__auth/abilitiesanswers["*"]for an app withoutBackend::abilities(), so$candoesn't hide what the app itself grants.- The user's channel is followed for the abilities alone, with Laravel's
default
App.Models.User.{id}when notifications are off. Abilities::allowsrecords a live dependency (elyra:abilities), which is how a changed list re-runs the live queries that asked.- The package listens to spatie's
RoleAttachedEvent,RoleDetachedEvent,PermissionAttachedEventandPermissionDetachedEvent, which spatie sends withpermission.events_enabled. A broadcast that fails is reported, not thrown: the role change stands, and the desktop reads the list again on focus or reconnect.config/elyra.phpis published with theelyra-configtag. - The backend model
make:resourcewrites hascan, so a record'scanreaches the views through the command.
Open questions
- Combining: the app's grant and the user's ability (recommended: the list only narrows), the user's alone, or either?
- The source: a dedicated
GET /api/abilitiesroute (recommended), or anabilitiesfield in/api/user? - Per record: generated views read
record.can.*when the API sends it and fall back to the user's ability (recommended), or user abilities only? - Freshness: sign-in, focus, reconnect and an
AbilitiesChangedbroadcast (recommended), or sign-in only? - Offline: keep the last list per user and use it (recommended), or deny every gated command while offline?
- The refusal's kind:
denied, as a403from Laravel (recommended), orforbidden, as the app's own missing grant? - The Laravel side:
Route::elyraAbilities()in the package, reading spatie's permissions when installed and configured Gate abilities otherwise (recommended), or only documented? - In the generated views: hide what the user can't do (recommended), or show it disabled?
Decisions
Settled on 2026-10-21, all as recommended:
- Both must agree: a
cancommand runs from the webview when the app grants the ability and the signed-in user has it. Laravel's list only narrows. - A dedicated route,
GET /api/abilities, answering a list of strings (*covers a namespace);.abilities_route(..)moves it. - Per record: the generated views read
record.can.*when the API sends it, and fall back to the user's ability. - Freshness: read at sign-in, on focus, on reconnect, and at once on an
AbilitiesChangedbroadcast on the user's channel. - Offline: the last list is kept per user and used.
- The kind is
denied, as a403from Laravel; the app's own missing grant staysforbidden. Route::elyraAbilities()inelyracode/elyra-backend, with spatie's permissions when installed andconfig('elyra.abilities')through the Gate otherwise, andAbilitiesChanged.- The generated views hide what the user can't do.