Auth
laravelui5/auth adds password-and-session authentication to a LaravelUi5 application, with a UI5 login mini-app — sign in, sign out, forgot password, set new password.
It is a separate MIT package, not part of Core. Core supplies the artifact runtime the package registers into; everything else lives in Auth. You can run Core without it, and you can run Auth on Core alone, no SDK required.
Requirements
| PHP | ^8.4, the same floor as Core |
| Core | laravelui5/core: ^2.0 (which in turn pins the supported Laravel version) |
| Database | Laravel's stock auth tables — users and password_reset_tokens |
Installation
composer require laravelui5/authThe package is distributed through Packagist, like laravelui5/odata. The service provider is auto-discovered through extra.laravel.providers, so there is nothing to register by hand. On boot it loads the package routes, registers the UI5 AuthModule with Core's infrastructure collector, and points Laravel's password-reset notification at the package's own route.
What your application must provide
The package ships its own routes but redirects to two named routes it does not define. Both must exist in the consuming app:
| Route name | Used by | If missing |
|---|---|---|
home | every logout ends with redirect()->route('home') | logout throws RouteNotFoundException |
dashboard | the default post-login landing | login throws right after credentials validate |
Minimal wiring:
// routes/web.php
Route::get('/', HomeController::class)->name('home'); // public; logout lands here
Route::get('/dashboard', /* your post-login landing */)
->middleware('auth') // guests bounce to /login
->name('dashboard');Three further route names are optional — terms, privacy, cookies. The login screen renders a footer link for each only when the route exists, so leaving them undefined is safe.
What you get

public/assets/ci/logo-full.svg.| Method | URI | Name |
|---|---|---|
| GET | /login | login |
| POST | /auth/login | login.submit |
| POST | /auth/forgot-password | password.email |
| GET | /password/reset/{token} | password.reset |
| POST | /auth/reset-password | password.update |
| POST | /logout | logout |
Login is throttled at five attempts, the session is regenerated on success, and forgot-password responses are identical whether or not the address exists.
Intents — sequencing what happens after login
Credentials validating is not the same as the user being done. A host may need an onboarding step, a profile completion, an org setup. Auth models that as a loop, not a redirect.
The loop
POST /auth/login ──▶ authenticate ──▶ dispense() ──▶ intent
▲ │
│ ▼
satisfied ◀── POST /auth/intents/{kind}
│
▼
RedirectIntent ──▶ doneIntentDispenserInterface::dispense() is called once after credentials validate, then again after every satisfied intent. It always returns an Intent — never null. The loop ends when it returns a RedirectIntent, whose payload carries the final target.
public function dispense(Authenticatable $user, Request $request): Intent;What goes over the wire
POST /auth/login answers with the first intent already serialized:
{
"message": "login_success",
"next": { "kind": "redirect", "version": "1", "payload": { "target": "/dashboard" } }
}Every intent serializes to the same envelope — kind, version, payload — so the frontend switches on kind and nothing else. version exists so a payload shape can change without a new kind.
The catalog is closed
IntentKind is an enum with exactly two cases:
| Kind | Payload | Meaning |
|---|---|---|
redirect | { target } | you are done; go here |
org_setup | { personName } | collect organisation details first |
A closed catalog is deliberate. Auth owns the loop and the wire; it does not offer an open extension point where a host invents kinds the shipped UI5 app cannot render. Adding a kind is a change to the package, not a host concern.
Writing your own dispenser
The default resolves the intended URL and stops:
final class DefaultIntentDispenser implements IntentDispenserInterface
{
public function dispense(Authenticatable $user, Request $request): Intent
{
return new RedirectIntent(
$request->session()->pull('url.intended', route('dashboard'))
);
}
}Yours decides what still has to happen, in order, and falls through to the same ending:
final class MyIntentDispenser implements IntentDispenserInterface
{
public function dispense(Authenticatable $user, Request $request): Intent
{
if ($user->partner_id === null) {
return new OrgSetupIntent(personName: $user->name);
}
return new RedirectIntent(
$request->session()->pull('url.intended', route('dashboard'))
);
}
}Bind it in your own service provider:
$this->app->singleton(
\LaravelUi5\Auth\Contracts\IntentDispenserInterface::class,
\App\Auth\MyIntentDispenser::class,
);Auth registers its default with singletonIf, so your binding wins — it never clobbers yours.
The dispenser must terminate
It is called again after every satisfaction. A dispenser whose condition never flips keeps handing out the same intent and the user never reaches a destination. Make sure the step you dispense actually changes the state you test.
Satisfying org_setup
The frontend posts to POST /auth/intents/org-setup (auth-guarded). The route resolves an OrgSetupHandlerInterface — which Auth does not ship.
public function handle(Authenticatable $user, OrgSetupRequest $request): IntentResult;That is intentional: a host that dispenses OrgSetupIntent without binding a handler fails loud at request time. Real organisation state belongs to the host, so the host is on the hook for it — a silent no-op default would let a half-onboarded user through.
The handler returns an IntentResult in one of three states:
| Factory | status | Effect |
|---|---|---|
IntentResult::satisfied() | satisfied | the controller calls dispense() again; the next intent rides back in next |
IntentResult::needsMoreInput($payload) | needs_more_input | next_step carries the payload; the loop stays on this intent |
IntentResult::error($errors) | error | errors carries the messages; nothing advances |
Only satisfied advances the loop. The response always has the same four keys — status, next_step, errors, next — with next populated on satisfied alone.
The password-reset flow
Four steps, two of them redirects into the UI5 app, and two guards that are easy to miss.
1. Request a link. POST /auth/forgot-password runs Laravel's password broker and answers {"message": "reset_link_sent"} — identically whether or not the address exists. Do not "improve" this in a host wrapper; the uniform answer is what stops the endpoint being an account oracle.
2. The mail. Auth repoints Laravel's built-in notification at its own route on boot:
ResetPassword::createUrlUsing(fn ($notifiable, string $token) => route('password.reset', [
'token' => $token,
'email' => $notifiable->getEmailForPasswordReset(),
]));So the stock ResetPassword notification is used — you customise the mail the ordinary Laravel way, and the URL stays correct.
3. The link lands. GET /password/reset/{token} pre-validates the token before sending the user anywhere:
if (! $user || ! $broker->tokenExists($user, $token)) {
return redirect()->route('login');
}An expired or forged link bounces to login instead of rendering a form that would fail on submit. A valid one redirects into the UI5 app at set-password/{token}/{email}.
4. Submit. POST /auth/reset-password runs Password::reset(), force-fills the hash, rotates remember_token, and fires PasswordReset. Anything other than success throws a ValidationException keyed on email with the message reset_failed. Success returns {"message": "reset_success", "redirect": "…/login"}.
The token is validated twice, on purpose
Step 3 is a courtesy check; step 4 is the source of truth. Removing step 3 would still be secure — it would just land users on a doomed form.
Customising the UI5 views
There are three seats, in increasing order of commitment.
Your logo
The manifest looks for a host-supplied logo first and falls back to the package's own:
$path = public_path('assets/ci/logo-full.svg');
$asset = file_exists($path)
? asset('assets/ci/logo-full.svg')
: asset('vendor/laravelui5/auth/logo-full.svg');Drop your own SVG at public/assets/ci/logo-full.svg and the login screen picks it up — no config, no publish step.
Provide the file
The package registers no publishes(), so the fallback path resolves to an asset that is not in your public/ tree. Without your own file the login screen renders a broken image. Treat public/assets/ci/logo-full.svg as required, not optional.
Footer links
The manifest contributes terms, privacy and cookies — each rendered only when a route of that name exists in your app:
'terms' => Route::has('terms') ? route('terms') : null,Define the route and the link appears. Leave it undefined and the footer simply omits it.
Wording
Every string on every screen comes from resources/ui5/i18n/ — i18n.properties, i18n_en.properties, i18n_de.properties, keyed by screen (forgotPassword.*, setPassword.*, …). Changing wording means changing those files, which means the third seat.
Replacing the app
The shipped resources/ui5/ is compiled output — a Component-preload.js plus the manifest and i18n bundles. There is no partial override: to change views, wording, or theme you take over the whole app.
Point Core's source resolution at your own copy with a .ui5-sources.php in your project root:
<?php
return [
'modules' => [
'LaravelUi5\\Auth\\AuthModule' => '../my-auth-app/',
],
];Core's WorkspaceStrategy then serves your project's webapp/ live instead of the packaged build — the same mechanism auth-host uses to develop the app against a running host. Your fork must keep the routes and the wire contracts above; everything visual is yours.
This is a fork, not a hook
Taking this seat means owning UI5 sources you now maintain against future Auth releases. For a logo, footer links, or a language, use the two seats above instead.
Further reading
Full README, changelog and issues: github.com/pragmatiqu/laravelui5-auth