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 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 three links beneath the card are yours to point at your own pages; the card takes your logo from public/assets/ci/logo-full.svg.
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 OrgSetupHandlerInterfacewhich 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 looks for a host-supplied logo first and falls back to the package's own:

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

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