August 11, 2026

The auth UI: eight screens, zero frontend wiring

By Manuel Christlieb — Staff Engineer

Part 4 of the Be Your Own Identity Provider series. Part 3 covered the relying party.

Part 2 ended on a deliberate hole: the server package owns every authentication flow but ships no views at all. Bind nothing and the first request to /login throws MissingAuthViewException.

That’s a defensible design and a terrible out-of-the-box experience, which is what the third package is for.

Eight contracts, eight pages

bambamboole/laravel-oidc-ui binds all eight view contracts the server declares — LoginView, RegisterView, PasswordResetRequestView, PasswordResetView, EmailVerificationView, PasswordConfirmationView, TwoFactorChallengeView, and ConsentView — to Lattice pages. Login, registration, password reset, email verification, password confirmation, the 2FA challenge, and the OAuth consent screen, as real React, described in PHP.

The bindings happen in register(), which is the only reason overriding one is pleasant. Every provider’s register() completes before any boot() runs, so a bind in your provider always wins:

$this->app->bind(LoginView::class, CustomLoginPage::class);

One contract, one screen. Every other page keeps the package’s default, and nothing gets forked. The consent page isn’t a special case — it’s the same seam, which was the whole point of routing it through a contract in part 2 instead of Passport’s authorizationView().

The part I like: no frontend step

Back in Part 10 of the Lattice series I wrote about distributing a Lattice component as a plain Composer package — the PHP class and its React renderer in one dependency, no npm publish, because Lattice’s Vite plugin scans vendor/composer/installed.json and compiles the package’s TSX straight into the consuming app’s bundle.

This package is the first real thing I built on that. Its composer.json declares:

"extra": {
    "lattice": {
        "discover": ["src"],
        "plugin": "resources/js/plugin.ts"
    }
}

discover makes Lattice find the package’s attributed classes — pages, actions, the #[AsLayout('auth')] auth layout — without the host app touching config('lattice.discover'). plugin hands its React components to the app’s bundle.

So the install is composer require bambamboole/laravel-oidc-ui, and there is no second step. No npm package, no import to add, no plugin array to edit, no publishing of stubs — the frontend arrives with the backend, versioned together, because it is the same artifact. Writing a package that consumed the mechanism was also the best test of it: a plugin distribution that only ever gets used by demo components proves nothing.

Passkeys need actual browser APIs

Most of these screens are ordinary forms, which Lattice already describes in PHP. Two things aren’t: the WebAuthn ceremonies. navigator.credentials.create() and .get() can’t be expressed as a schema — you need real client-side code at exactly the right moment in the flow.

So the package ships two custom Lattice components, oidc.passkey-registration and oidc.passkey-verify, through the same plugin entry. They’re the escape hatch working as intended: describe the 95% in PHP, drop to React for the part that genuinely lives in the browser, and let both travel in one Composer package.

Building blocks, not just screens

The auth flow screens are the obvious half. The other half is the part every app has to build itself: a security section in the settings page. That one can’t be a package-owned route — it lives inside your app’s layout, your navigation, your authorization rules.

So the package ships it as Lattice primitives instead, discovered like everything else:

KindID
Actionoidc.two-factor.enable / .disable / .revoke-factor / .regenerate-recovery-codes
Actionoidc.send-verification-email
Formoidc.two-factor.confirm
Fragmentoidc.two-factor-setup / oidc.recovery-codes
Tableoidc.two-factor.methods

Composed into your own page, a working 2FA settings section is this:

Stack::make('two-factor')->schema([
    Action::use(EnableTwoFactorAuthenticationAction::class),
    Action::use(EnableTwoFactorAuthenticationAction::class, ['provider' => 'webauthn']),
    Action::use(DisableTwoFactorAuthenticationAction::class)->visible($twoFactorEnabled),
    Action::use(RegenerateRecoveryCodesAction::class)->visible($twoFactorEnabled),
    Table::lazy(TwoFactorMethodsTable::class),
]);

TOTP enrollment with its QR code, passkey registration, listing and revoking factors, recovery codes — the logic stays in the package, the layout is yours. That’s the same trade as the view contracts, one level down.

That’s the series

Four posts: the protocols and why you’d own them, the provider, the relying party, and the UI. What I set out to prove was that a Laravel app can be the identity provider — not integrate with one — and that the pieces should be separable enough that you take only the half you need.

It’s version 0.x, the API still moves, and the auth engine’s more interesting corners — the post-login pipeline, MFA providers, social login — didn’t fit in four posts. The docs are at bambamboole.github.io/laravel-oidc, the code at github.com/bambamboole/laravel-oidc. If you run one of these in anger and it breaks, that’s the feedback I want most.