RFC 0005 — A Laravel backend: sign-in, a typed HTTP client, and its errors
Status: implemented (2026-10-10) · Target: 0.12.0 · see Decisions · the guide: docs/backend.md
Summary
Elyra is the desktop half of an app whose web half is Laravel, served by Askr or any other server. This RFC gives an Elyra app what it needs to work against that Laravel backend:
- an
Httpclient, Laravel'sHttpfacade in Rust; - a
Backendon top of it, which signs the user in with a Sanctum token kept in the OS keychain; - Laravel's error answers mapped to Elyra's. A
422becomes the sameValidationErrorsa local command returns, so a form shows the server's messages without knowing where they came from.
App::new().backend(Backend::new("https://crm.example.com"))
#[command(live, can = "customers.view")]
async fn customers_index(ctx: Ctx, query: CustomerQuery) -> elyra::Result<Page<Customer>> {
Ok(ctx.get::<Backend>().get("/api/customers").query(&query).json().await?)
}
<script>
import { auth } from "@elyra/runtime";
// auth.signIn(email, password) — a 422 lands per field, like any form
</script>
{#if $auth.signedIn}Hi {$auth.user.name}{:else}<SignIn />{/if}
This is the first of three. RFC 0006 brings Laravel's broadcasting in, so a change made on the web updates the open windows. RFC 0007 covers working offline and syncing.
Motivation
- Desktop and web, one product. The user's data lives in the Laravel app. The desktop app is another way in, so it needs to sign in as that user and call the same API.
- It's mostly glue, and easy to get subtly wrong. Laravel answers a failed
validation with a redirect unless the request asks for JSON. A token
belongs in the keychain, not in
localStorage. A redirect to another host must not carry the token. A401means the token is gone, and the UI must say so. Every app would rediscover these one at a time. - Elyra already has the halves.
Secrets(the keychain),ValidationErrorsin Laravel's exact shape (items.0.name), deep links, the event bus, live queries andmake:resource's forms. They only need to meet a backend.
Current state (0.11)
- No HTTP client in the framework.
reqwestis in the workspace for the AI SDK (rustls), and the updater usesureq. Secretsstores strings in the OS keychain: Keychain on macOS, Credential Manager on Windows, and Secret Service on Linux.ValidationErrorsserializes as Laravel's bag ({ "field": ["message"] }), and the generated forms render it per field.- Abilities gate what the webview may call. Nothing knows who the user is.
Design
Http: the client
A thin layer over reqwest, shaped like Laravel's facade. It's behind a new
http feature.
let res = ctx.get::<Http>()
.get("https://api.example.com/rates")
.query(&[("base", "NOK")])
.timeout(Duration::from_secs(10))
.retry(3, Duration::from_millis(200)) // on connect errors and 5xx, not 4xx
.send()
.await?;
let rates: Rates = res.json()?;
- It sends
Accept: application/json, and JSON bodies via.json(&body). - Every request has a timeout (30 s by default). Retries happen only on
connection errors and
5xx, and only for idempotent methods unless asked. - HTTPS is required, except for loopback, as for the updater.
- A redirect to another host drops
Authorizationand cookies. That's tested against a server that answers302to another host. - Logs (
elyra::http) show the method, the host, the path, the status and the duration. They never show the query, the body or headers, since those can hold personal data or the token.
Backend: the Laravel API
Backend is an Http with a base URL, the signed-in user's token, and
Laravel's errors.
App::new().backend(
Backend::new(config.string("backend.url"))
.token_route("/api/sanctum/token") // the defaults
.revoke_route("/api/sanctum/token") // DELETE
.user_route("/api/user"),
)
Each answer that isn't a success becomes a BackendError, which turns into
elyra::Error as a command expects:
| Laravel answers | BackendError |
What the app sees |
|---|---|---|
422 { message, errors } |
Validation(ValidationErrors) |
the same bag a local command returns: per field in a form, -32602 with the messages over MCP |
401 |
Unauthenticated |
the token is dropped from the keychain, and elyra:auth says signed out |
403 { message } |
Forbidden(message) |
a command error with Laravel's message |
404 |
NotFound |
a command error |
419 |
Expired |
as 401: tokens don't use sessions, so it means a misrouted request |
429 + Retry-After |
TooManyRequests { retry_after } |
a command error that says when to try again |
5xx |
Server(status) |
a command error, without the HTML of an error page |
| no connection, DNS, TLS, timeout | Unreachable |
a command error with kind: "offline" the UI can tell apart |
A successful body is decoded as the command's type. Laravel's two paginator
shapes both decode into Page<T>, the type make:resource already uses.
Plain paginate() already has Page's fields (data, total, per_page,
current_page, last_page). An API Resource's { data, links, meta }
carries the same numbers in meta. Page lives in elyra::db today, so it
moves to the framework (re-exported from elyra::db, where it is now). An app
whose data is all on the backend then needn't enable database.
Signing in
let auth = ctx.get::<Auth>();
auth.sign_in("ada@example.com", "secret").await?; // a 422 for bad credentials
let user: User = auth.user().await?; // GET /api/user, cached
auth.sign_out().await?; // DELETE the token, forget it
- The token route follows Laravel's own example for mobile apps. It gets
email,passwordanddevice_name, and answers the plain-text token. The device name is the machine's and the app's ("Ada's MacBook · CRM"), so the user recognizes it on the web's "devices" page and can revoke it. - The token lives only in the keychain, under the app and the backend's URL. It never reaches the webview, which calls commands and never sees a credential. A script injected into the page can't send the token anywhere; it can only do what commands allow.
- Signing out revokes the token on the server. That's best-effort: offline, it's forgotten locally all the same, and the user can revoke it from the web.
- A
401anywhere signs the user out locally and emitselyra:auth { signedIn: false, reason: "expired" }.
For the frontend, the runtime gets an auth store and calls. They run as
built-in commands, behind the existing guard:
import { auth, onSignedOut } from "@elyra/runtime";
await auth.signIn(email, password); // throws the validation bag on a 422
$auth // { signedIn, user }
await auth.signOut();
onSignedOut((reason) => toast("Please sign in again"));
Live queries over the backend
A live command that reads from the backend reads no table, so a write
wouldn't re-run it. Backend gives it keys instead:
- A
GETinside a live command depends on its resource path: the path without trailing ids and without the query./api/customers?page=2and/api/customers/12both depend onbackend:/api/customers. - A successful
POST,PUT,PATCHorDELETEinvalidates its resource path. Socustomers_storeon the backend re-runs the open lists, in this app. ctx.depends_onandctx.invalidateremain for anything that isn't shaped likeapiResource.
Changes made elsewhere, on the web or by another user, come with RFC 0006.
The Laravel side
Nothing beyond Sanctum, and three routes. The guide carries them, from Laravel's own example:
// php artisan install:api
Route::post('/sanctum/token', function (Request $request) {
$request->validate(['email' => 'required|email', 'password' => 'required', 'device_name' => 'required']);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => ['The provided credentials are incorrect.']]);
}
return $user->createToken($request->device_name)->plainTextToken;
});
Route::delete('/sanctum/token', fn (Request $r) => $r->user()->currentAccessToken()->delete())->middleware('auth:sanctum');
Route::get('/user', fn (Request $r) => $r->user())->middleware('auth:sanctum');
Testing: a fake backend
Like Laravel's Http::fake(), a test stubs the backend and asserts on what was
sent. There's no network and no PHP:
let app = TestApp::new(app()).fake_backend(|fake| {
fake.get("/api/customers", json!({ "data": [], "meta": { "total": 0 } }))
.post("/api/customers", 422, json!({ "message": "…", "errors": { "email": ["Taken."] } }));
});
let errors = app.invoke_validation_errors("customers_store", (input,)).await;
assert_eq!(errors["email"], ["Taken."]);
app.backend().assert_sent("POST", "/api/customers");
A signed-in user is one call: .signed_in_as(json!({ "id": 1, "name": "Ada" })).
Security
- The token stays in the keychain, never in the webview, logs or files. If the platform has no keychain (Linux without Secret Service), sign-in fails with a message saying so. There's no plain-text fallback.
- The backend's URL comes from the app, not the webview. HTTPS is required
except for loopback, and
Authorizationisn't carried across a redirect to another host. - The server decides what the user may do. Laravel's policies and the token's abilities apply as on the web. The app's abilities still say what the webview, or an MCP agent, may call. An agent calling a command acts as the signed-in user, through the same API.
Alternatives considered
- Sanctum's SPA (cookie) mode. It's made for a browser on the same domain, with CSRF cookies. A desktop app is the "mobile application" case, and Sanctum's docs say to use tokens for it.
- Call Laravel from the webview with
fetch. It's simpler, but the token would sit where page scripts can read it. It also bypasses commands, so middleware, MCP and live queries wouldn't see the calls. - Generate a client from an OpenAPI spec. That's useful when a spec exists, and could come later. Most Laravel apps don't publish one, and serde types written once do the job.
- A Laravel package (
elyra/laravel) first. Three routes don't need a package. RFC 0006 and 0007 may add server pieces worth packaging; that's the time.
Implementation plan
Http(featurehttp): the builder, JSON, timeouts, retries, the HTTPS rule, redirects and redacted logs. PlusTestApp's fake, with recorded requests.Backendand Laravel's errors: the mapping above,Page<T>from both paginator shapes, andBackendErrorintoelyra::Error(422asValidationErrors).Auth: sign-in, sign-out and the user. The keychain, the device name,401handling,elyra:auth, and the runtime'sauthstore and calls.- Live queries: resource-path keys for
GETs, and invalidation after writes. make:resource --backend /api/customers: the same generated screens, with commands that call the backend instead of the local database.- Docs, an example, and CI against a real Laravel app. That's
composer create-project laravel/laravel,install:api, the three routes andphp artisan serveon the CI runner, then a test that signs in, creates, gets a422, lists, and signs out.
Open questions
- How to sign in: Sanctum email/password tokens only in v1 (recommended; it's what Laravel documents for apps), or also OAuth with PKCE (Passport, the system browser and a deep-link callback) for SSO now?
- The Laravel side: the three routes in the guide (recommended for now), or
an
elyra/laravelcomposer package from the start? - Live-query keys: automatic, from resource paths (recommended: it fits
apiResource, and explicit keys stay available), or explicit only? make:resource --backend: in this RFC as step 5 (recommended: it's the payoff, generated screens over a Laravel API), or a later one?- CI against a real Laravel app (recommended: the only way to know the
422and paginator shapes stay right), or fakes only? - No keychain: refuse to sign in (recommended), or fall back to a
0600file?
Decisions
Settled on 2026-10-10, all as recommended:
- Sanctum email/password tokens in v1. OAuth with PKCE (SSO) can come
later;
Authkeeps the sign-in method separate so it can. - The three routes in the guide. A composer package waits until RFC 0006 or 0007 needs server pieces.
- Automatic live-query keys from resource paths, with
depends_on/invalidatefor the rest. make:resource --backendis step 5 of this RFC.- CI against a real Laravel app, on the runner with
php artisan serve. - No keychain, no sign-in. There's no plain-text fallback.