Skip to content

Troubleshooting ​

Start from the symptom. Most of what goes wrong in an SDK installation has one of a handful of causes, and the ones that cost the most time are the ones that fail quietly — an empty list, a stale file, a gate that is simply not there yet.

The four commands that answer most questions ​

bash
php artisan ui5:sync --dry     # what would change in the database, without changing it
php artisan ui5:explain --app=com.acme.timesheet --partner=42
php artisan ui5:help           # validates every help document; writes nothing
php artisan ui5:nav            # compiles navigation, or fails and writes no cache

ui5:explain is the one to reach for whenever a person cannot do something they should: it resolves their abilities the way the runtime does and shows where each grant came from — which role or group, which assignment row, valid over which window. --at="2026-03-01 09:00:00" asks the same question as of another moment.

Symptoms ​

A list is empty for a user who should see rows ​

Check this before you check your query. A scoped entity set resolves the actor from the bound context; with no context bound the scope falls through to 1 = 0 and the response is a perfectly successful empty list.

The usual cause is a missing middleware in the OData stack:

php
'odata_middleware' => [
    'web',
    \LaravelUi5\Core\Http\Middleware\FetchCsrfToken::class,
    \LaravelUi5\Core\Http\Middleware\ResolveODataEndpoint::class,
    \LaravelUi5\Core\Http\Middleware\EnsureODataAuthenticated::class,
    \LaravelUi5\Sdk\Http\Middleware\BindSdkContextForOData::class,   // ← this one
],

If it is present, the next question is the read gate and then the scope itself. See the request edge.

A 403 where the user should have access ​

bash
php artisan ui5:explain --app=<module namespace> --partner=<id>

Read the Access row. If it is DENIED, the grant is missing, expired, or not yet valid. Denied rows print no sources; run the command again with --at= a moment you expect the grant to hold, and read the window from the granted row. If it is GRANTED and the request still fails, the actor resolved is not the partner you think: check that the signed-in user's partner_id points where you expect.

Someone can do something they should not ​

The gate is on the app, not on the data. An app's #[Access] does not protect its OData endpoint; that is what #[Read] is for, and it is a deliberate cut. See the read gate.

A request fails with "is not synced" ​

MissingModuleException or MissingAbilityException. Neither is a permission problem: an artifact declares a gate, and the declaration has no row to resolve against. You are in the window between deploying code and running ui5:sync.

bash
php artisan ui5:sync

The two are separate on purpose. MissingModuleException means the module itself was never synced. MissingAbilityException means the module is there but a declared #[Access] or #[Act] is not — a partial or interrupted sync, or a gate added to an app that was already known.

Both are loud by design, and they used to be the opposite: the second one resolved to "no gate" and the request went through, so a newly declared gate protected nothing and nothing said so. If you are on a version before 1.2.0, that is the behaviour you have.

A locked login can still sign in ​

Expected today, and not a misconfiguration. The SDK administers is_locked, must_change_password, login_counter and last_login; nothing in the SDK sits in your authentication path, and laravelui5/auth does not read them either. Until your login path checks them, the lock is an administrative record rather than an enforced control. The full picture, and the listener to write in the meantime, is on the login page.

The shell has no chrome ​

Not a stale bundle — that cannot happen any more, because the bundle is served from the package at a version-carrying URL. Check the wiring instead: your host's two Blade seats (resources/views/ui5/{head,foot}.blade.php calling @includeIfSdk), and whether the app calls LaravelUi5.init(this) before routing starts.

The shell renders, but in the wrong font, or the avatar is broken ​

ui5:publish has not run. The stylesheet points at /sdk/fonts/… and the fallback avatar at /sdk/avatars/…, and those two are what the command copies:

bash
php artisan ui5:publish --force

For a shell that renders but misbehaves, the shell overview has the per-capability symptom table.

F1 says help is unavailable, or a page you wrote never appears ​

Two different problems that look alike.

Nothing works — the compiled output is missing. ui5:help --all writes into storage/, which is not in your repository; a bare ui5:help validates and writes nothing. Add the flag to your deploy.

One page is missing — it was probably never compiled. Only a declared UUID is built: a module's #[Help] root and the helpUuid()s of Customizing entries. Binding a UUID in a view with <core:Context uuid="…"> is not a declaration, and the folder is skipped without a word. This is the trap everyone meets once; the help concept opens with it, and the build pipeline carries the table of what the command reports and — more usefully — what it passes over in silence.

A navigation entry shows "Missing i18nKey …" ​

A missing translation key does not fail ui5:nav; the label falls back to a readable marker. After running the command:

bash
grep -o "Missing i18nKey[^']*" bootstrap/cache/ui5-nav.php | sort -u

Empty output means every label resolved. Remember that a UI5 locale bundle needs the key in bothi18n.properties and each locale file.

CachedNavigationService throws on the first request ​

bootstrap/cache/ui5-nav.php is missing. Run ui5:nav, and put it in the deploy chain before the release takes traffic.

If the file is there and the request dies inside require — an undefined __set_state(), or an undefined array key — it was written before 1.2.0. Those files are unreadable: the command wrote live objects into them and nested the entry map one level too deep, and it reported success either way. Re-run ui5:nav on 1.2.0 and the file is replaced with a readable one.

An address save is rejected with a 422 ​

The address model enforces what the schema cannot. A row must be either structured (address_line_1, city, country_code) or explicitly without a postal address — and in the second case it needs formatted_address and both coordinates. Coordinates travel as a pair on either branch, so a row carrying only one is refused as well. A partner may hold at most one Primary address. The message names which rule fired; the reasoning is on the addresses page.

If a rejected save appears to do nothing in the browser — no message, no error — the host is turning that 422 into a redirect. Laravel renders an exception as JSON when expectsJson() says so; an app that passes its own callback to shouldRenderJsonWhen() replaces that rule rather than extending it, and a callback like fn ($request) => $request->is('api/*') never matches the UI5 action routes, which live under the ui5: prefix. The response becomes a 302, LaravelUi5.call follows it, and the error is swallowed. Make the callback a superset — $request->is('api/*') || $request->expectsJson() — or drop it and keep Laravel's default.

An export returns 404 or 422 ​

  • 404 — the app does not implement ProvidesExportInterface, or ExportArtifactResolver is missing from config('ui5.artifact_resolvers').
  • 422, "only filtered exports are supported" — the row count exceeds ui5.export.max_rows (500 by default). That is the guard working; narrow the filter or raise the key.
  • 422 naming a format — no writer is bound for it. CSV ships; xlsx is yours to bind.

See table export.

A setting override does not take effect ​

An override is delivered where the request resolves the artifact that declares the setting. So an action does not automatically see the settings of the app it belongs to, and a tile provider sees the dashboard's. Check which artifact the request actually resolved before assuming the write failed — ui5:sync --dry will confirm the setting exists, and the settings pages describe the resolution.

When nothing above fits ​

Work from the outside in. The layers are few and each has one question:

  1. Is the artifact resolved? A path that matches no artifact never reaches your code.
  2. Is someone signed in, and is the actor the partner you expect?
  3. Does the ability row exist? ui5:sync --dry.
  4. Is the grant live right now? ui5:explain, with --at if the window is the suspect.
  5. Is the compiled output current? The four build outputs in deployment — a stale one of these is behind a surprising share of "it works locally".

See also ​