Authentication pages
Build the eight Livewire-backed auth views a theme must ship, and preserve every wire:model, wire:submit and @error binding while restyling freely.
On this page
Authentication pages#
Sign-in, registration and account recovery are the surfaces where a broken theme fails loudest — a mis-wired form doesn't look wrong, it silently stops logging people in. This article covers the eight auth views your theme must provide, how each one is rendered, and the one rule you cannot break: you may restyle everything, but you must preserve every binding exactly.
If you haven't yet, read Layouts and the page shell first — auth pages render through your layouts/app.blade.php, so the shell has to be right before these pages will.
The eight views a theme provides#
Each auth surface is a Blade view your theme ships under themes/{name}/auth/. All eight are required for a complete theme — Core privileges none of its own and there is no fallback styling, so a missing one resolves to the neutral Core holding page.
| View file | Rendered by | What it does |
|---|---|---|
auth/login.blade.php | App\Livewire\Client\Auth\Login | Sign in (identifier → password steps) |
auth/register.blade.php | App\Livewire\Client\Auth\Register | Create an account (multi-step) |
auth/forgot-password.blade.php | App\Livewire\Client\Auth\ForgotPassword | Request + verify a reset code, set new password |
auth/reset-password.blade.php | App\Livewire\Client\Auth\ResetPassword | Set a new password from a mailed token link |
auth/verify-email.blade.php | App\Livewire\Client\Auth\VerifyEmail | Resend / confirm email verification |
auth/two-factor-verify.blade.php | App\Livewire\Client\Auth\TwoFactorVerify | Enter a TOTP or backup code |
auth/authorization.blade.php | App\Livewire\Client\Auth\Authorization | Post-login gate (email/sms/2fa code, or ban notice) |
auth/complete-profile.blade.php | App\Livewire\Client\Auth\CompleteProfile | Fill in required profile fields after a social sign-in |
There are two separate password-recovery views because there are two flows: forgot-password is the self-service code flow (request → verify → reset, all on one component with a $step), while reset-password handles the tokenised link a user clicks from an email. Ship both.
How an auth page is rendered#
Every auth page is a Livewire component, not a controller action. Unlike the rest of the client area, these components carry no #[Layout] attribute — they select your theme's view and layout by hand inside render():
// App\Livewire\Client\Auth\Login::render()
public function render()
{
$view = themeViewName('auth.login'); // "themes.{active}.auth.login"
if (view()->exists($view)) {
return view($view, $data)->layout(themeViewName('layouts.app'));
}
return view(themeViewName('auth.login'), $data);
}Two consequences for you:
- Your view is wrapped by your own `layouts/app.blade.php`. That layout must render
{{ $slot }}(the Livewire component body) and include@livewireStyles/@livewireScripts. See Layouts and the page shell for the slot bridge. - The component owns the state and actions. Your view only declares the markup and the bindings that connect to it. You never write the sign-in logic — you wire inputs to the component's public properties and buttons to its public methods.
themeViewName() and the other resolution helpers are documented in the Helper reference.
The golden rule: preserve every binding exactly#
You are free to change layout, spacing, colour, copy, iconography, and which components you use. You must keep, byte-for-byte, four things on every form:
- `wire:model` — binds an input to a public property on the component. The property name is the contract;
wire:model="username"must stayusername. - `wire:submit` / `wire:click` — dispatches a public method (
login,continueToCredentials,resendVerification, …). Rename it and nothing happens on submit. - `@error('field')` — renders the server-side validation message for
field. The key must match the property/validation rule. - `name` attributes — password managers and the browser's autofill key off
nameandautocomplete.name="username"+autocomplete="username"is what lets a saved credential populate.
Here is a login form reduced to only what matters. Before — the reference theme's component-built version:
<form wire:submit.prevent="login" class="mt-7 space-y-4">
<input type="text" name="username" value="{{ $username }}"
autocomplete="username" class="sr-only" tabindex="-1" aria-hidden="true">
<x-auth.password name="password" :label="__('Password')"
wire:model="password" required autocomplete="current-password" autofocus />
<label class="flex items-center gap-2.5">
<input type="checkbox" wire:model="remember" class="...">
<span>@lang('Keep me signed in')</span>
</label>
<x-auth.button type="submit" target="login">@lang('Sign in')</x-auth.button>
</form>After — the same form completely restyled with your own plain markup and no x-auth.* at all. Notice every wire:*, name, and @error key is identical; only the presentation changed:
<form wire:submit.prevent="login" class="my-form">
{{-- hidden identifier so password managers associate the credential --}}
<input type="text" name="username" value="{{ $username }}"
autocomplete="username" class="sr-only" tabindex="-1" aria-hidden="true">
<label for="password" class="my-label">{{ __('Password') }}</label>
<input id="password" type="password" name="password"
wire:model="password" required autocomplete="current-password" autofocus
class="my-input @error('password') my-input--invalid @enderror">
@error('password') <p class="my-error" role="alert">{{ $message }}</p> @enderror
<label class="my-check">
<input type="checkbox" wire:model="remember">
<span>{{ __('Keep me signed in') }}</span>
</label>
<button type="submit" class="my-btn" wire:loading.attr="disabled" wire:target="login">
{{ __('Sign in') }}
</button>
</form>Both submit to the same login() method, bind the same password / remember properties, and surface the same errors. That is the whole discipline: restyle the container, keep the wiring.
Two more binding details worth copying from the reference:
- The hidden `username` field on the password step. The login flow is two-step (identifier, then password), so the password screen keeps an off-screen
name="username"input carrying the already-entered value — that is what lets a password manager saveusername+passwordas one credential. Keep it. - `wire:loading.attr="disabled"` + `wire:target="login"` disables the submit button while that specific action is in flight, so a double-click can't fire two sign-ins.
The bindings for each view#
When you build a view, read the matching component's public properties and public methods — those are the API. The key ones, verified against the components:
| View | wire:model properties | wire:submit / wire:click methods |
|---|---|---|
login | username, password, remember | continueToCredentials, backToIdentifier, forgotPassword, login |
register | firstname, lastname, email, password, password_confirmation, country_code, mobile, agree, code | continueToProfile, continueToSecure, verifyEmailCode, resendEmailCode, backToEmail, backToProfile, register |
forgot-password | email, code, password, password_confirmation | sendResetCode, verifyResetCode, resendResetCode, backToRequest, resetPassword |
reset-password | token, email, password, password_confirmation | resetPassword |
verify-email | — | resendVerification |
two-factor-verify | code | verify |
authorization | code | verify, resendCode |
complete-profile | username, country_code, mobile, address, city, state, zip | submit |
register, forgot-password and authorization are multi-step: the component exposes a $step (or $type) property and you render different markup per value with @if($step === 'credentials'). The reset-password view carries token and email in hidden inputs (<input type="hidden" wire:model="token">) that came from the emailed link — bind them, don't drop them.
Links between auth pages use the client route names, not hardcoded paths: route('client.login'), route('client.register'), route('client.password.request'), route('client.authorization'), and the sign-out form posts to route('client.logout') with @csrf.
The shared x-auth.* component kit#
Core ships an anonymous-component kit under resources/views/components/auth/ that the reference theme leans on. You are free to use it, ignore it, or override individual pieces — it is a convenience, not a contract.
| Component | Purpose |
|---|---|
<x-auth.shell> | Full-page auth chrome (background, brand, centring) — wraps the card |
<x-auth.card> | The bordered panel; <x-slot:footer> for the "New here?" line |
<x-auth.heading eyebrow="…"> | Mono eyebrow + title (+ optional :subtitle) |
<x-auth.input name label wire:model> | Labelled text input with inline @error |
<x-auth.password name label wire:model> | Password field with reveal toggle |
<x-auth.otp name wire:model> | 6-digit code input |
<x-auth.button type target :loadingText> | Submit button with a loading state tied to target |
<x-auth.alert type> | Error / success message block |
<x-auth.divider>, <x-auth.social>, <x-auth.resend>, <x-auth.back-link> | Divider, social buttons, resend-code timer, back link |
These components simply forward your bindings — <x-auth.input name="email" wire:model="email" /> merges wire:model="email" straight onto the underlying <input>. Because they resolve through Laravel's default component finder (not per-theme), every theme sees the same kit. If you want your own look and don't want to fight the kit's styling, drop it entirely and write plain markup as in the "after" example above — the only thing that must survive is the wiring. This is distinct from the overridable x-theme.* storefront kit covered in Components.
Two traps that silently break auth views#
Auth forms are dense with directives and translations, which is exactly where two Blade parser quirks bite.
1. A Blade directive inside a component tag renders nothing. Blade's component tokenizer does not understand @if / @endif, so this component vanishes and its other attributes leak onto the page as literal text:
{{-- BROKEN: the whole input disappears, "required" leaks as text --}}
<x-auth.input name="mobile" wire:model="mobile" @if($phoneRequired) required @endif />Move the condition out, or pass a bound attribute the component understands:
{{-- OK --}}
<x-auth.input name="mobile" wire:model="mobile" :required="$phoneRequired" />(The register form once shipped with no phone input for exactly this reason.)
2. `@lang()` with a nested function call in its replacement array renders the string literally. The @lang directive's argument parser chokes on nested parentheses, so a call inside the array is not evaluated — the raw :placeholder string ships to the page:
{{-- BROKEN: renders the literal text, not the substituted amount --}}
@lang('Not enough balance — add :amount', ['amount' => showAmount($due)])Use {{ __() }} (a normal PHP expression) instead, or precompute the value in an @php block:
{{-- OK --}}
{{ __('Not enough balance — add :amount', ['amount' => showAmount($due)]) }}A simple nested __() — @lang('Configure :name', ['name' => __($product->name)]) — happens to survive, but the safe habit is: any array argument → use `{{ __() }}`. Static-parameter @lang('Sign in') with no array is always fine.
Next#
You now have the interactive surfaces wired. Continue with Components: using and overriding x-theme.* to learn the storefront component kit, or jump to Testing your theme to verify every auth flow actually signs a real account in and out before you publish.