Skip to content

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
Corelaravelui5/core: ^2.0 (which in turn pins the supported Laravel version)
DatabaseLaravel's stock auth tables — users and password_reset_tokens

Installation ​

bash
composer require laravelui5/auth

The 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 nameUsed byIf missing
homeevery logout ends with redirect()->route('home')logout throws RouteNotFoundException
dashboardthe default post-login landinglogin throws right after credentials validate

Minimal wiring:

php
// 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 ​

The sign-in screen: a logo above a card with login and password fields, a forgot-password link, a keep-me-signed-in checkbox and a Continue button, with privacy, legal and cookie links beneath it.
The shipped sign-in screen. The logo above the card is your own, taken from public/assets/ci/logo-full.svg; the three links beneath it point at your pages.
MethodURIName
GET/loginlogin
POST/auth/loginlogin.submit
POST/auth/forgot-passwordpassword.email
GET/password/reset/{token}password.reset
POST/auth/reset-passwordpassword.update
POST/logoutlogout

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  ──▶  done

IntentDispenserInterface::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.

php
public function dispense(Authenticatable $user, Request $request): Intent;

What goes over the wire ​

POST /auth/login answers with the first intent already serialized:

json
{
  "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:

KindPayloadMeaning
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:

php
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:

php
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:

php
$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.

php
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:

FactorystatusEffect
IntentResult::satisfied()satisfiedthe controller calls dispense() again; the next intent rides back in next
IntentResult::needsMoreInput($payload)needs_more_inputnext_step carries the payload; the loop stays on this intent
IntentResult::error($errors)errorerrors 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:

php
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:

php
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.

The manifest publishes the URL of one host-supplied file, and the screens render it above the card:

php
$logo = file_exists(public_path('assets/ci/logo-full.svg'))
    ? asset('assets/ci/logo-full.svg')
    : null;

Drop your own SVG at public/assets/ci/logo-full.svg and all four screens pick it up — no config key, no publish step.

There is no fallback. The package ships no logo of its own, because a foreign brand on your sign-in screen would be worse than no brand at all. Leave the file out and the screens render without a logo, which is a layout the views handle rather than a broken image.

The manifest contributes terms, privacy and cookies — each rendered only when a route of that name exists in your app:

php
'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
<?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