daikazu/laravel-frontdoor
Passwordless auth for Laravel: users sign in with one-time email codes. Session-based, no migrations required. Driver-based account providers (includes testing driver), optional registration, Livewire components, rate limiting, events, and deterministic avatars.
A driver base Passwordless authentication for Laravel applications. Users log in by receiving a one-time code via email. No database migrations required. Session-based authentication with extensible account providers.
Install the package via Composer:
composer require daikazu/laravel-frontdoor
Publish the configuration file:
php artisan vendor:publish --tag="laravel-frontdoor-config"
Get up and running in 2 minutes using the built-in testing driver.
Open config/frontdoor.php and add email addresses to try the package with:
'accounts' => [
'driver' => 'testing',
'drivers' => [
'testing' => [
'users' => [
'jane@example.com' => [
'name' => 'Jane Doe',
],
'john@example.com' => [
'name' => 'John Smith',
],
],
],
],
],
Add the navigation component to your layout:
<x-frontdoor::nav-login />
That's it! Users can now:
New users (when registration is enabled) follow a slightly different flow:
Laravel Frontdoor uses a driver-based system for looking up user accounts. The active driver is set via the accounts.driver config key and determines where accounts are stored and looked up.
When a user attempts to log in:
findByEmail() methodAccountData object if the account exists, or null if notThe package ships with a testing driver for development and trying out the package. It stores seed users from your config and any new registrations in cache. It is not intended for production use.
'accounts' => [
'driver' => 'testing',
'drivers' => [
'testing' => [
'users' => [
'admin@example.com' => [
'name' => 'Admin User',
'phone' => '+1-555-0100',
'metadata' => ['role' => 'admin'],
],
],
],
],
],
The testing driver supports registration out of the box. To try it, enable registration in config:
'registration' => [
'enabled' => true,
],
New accounts registered through the UI are stored in cache and persist until the cache is cleared.
For production use, create a driver class that implements AccountDriver and register it in config. There are two ways to do this:
Option A: Named driver — add an entry to the drivers array and reference it by name:
'accounts' => [
'driver' => 'salesforce',
'drivers' => [
'salesforce' => \App\Frontdoor\SalesforceAccountDriver::class,
],
],
Option B: FQCN — set the driver directly to the class name:
'accounts' => [
'driver' => \App\Frontdoor\SalesforceAccountDriver::class,
],
Both approaches resolve the class from Laravel's service container automatically. Named drivers are useful when you want to switch between drivers via environment variables (e.g. FRONTDOOR_ACCOUNT_DRIVER=salesforce).
Implement the AccountDriver interface with two methods:
<?php
namespace App\Frontdoor;
use App\Models\User;
use Daikazu\LaravelFrontdoor\Contracts\AccountData;
use Daikazu\LaravelFrontdoor\Contracts\AccountDriver;
use Daikazu\LaravelFrontdoor\Support\SimpleAccountData;
class DatabaseAccountDriver implements AccountDriver
{
public function findByEmail(string $email): ?AccountData
{
$user = User::where('email', $email)->first();
if (! $user) {
return null;
}
return new SimpleAccountData(
id: (string) $user->id,
name: $user->name,
email: $user->email,
phone: $user->phone,
avatarUrl: $user->avatar_url,
metadata: ['role' => $user->role],
);
}
public function exists(string $email): bool
{
return User::where('email', $email)->exists();
}
}
Then register it in config (either approach works):
// Named driver
'accounts' => [
'driver' => 'database',
'drivers' => [
'database' => \App\Frontdoor\DatabaseAccountDriver::class,
],
],
// Or FQCN
'accounts' => [
'driver' => \App\Frontdoor\DatabaseAccountDriver::class,
],
That's it. No service provider registration needed.
To support registration, implement CreatableAccountDriver instead. This extends AccountDriver with two additional methods: registrationFields() defines the form fields shown to the user, and create() handles account creation with the submitted data.
<?php
namespace App\Frontdoor;
use App\Models\User;
use Daikazu\LaravelFrontdoor\Contracts\AccountData;
use Daikazu\LaravelFrontdoor\Contracts\CreatableAccountDriver;
use Daikazu\LaravelFrontdoor\Support\RegistrationField;
use Daikazu\LaravelFrontdoor\Support\SimpleAccountData;
class DatabaseAccountDriver implements CreatableAccountDriver
{
public function findByEmail(string $email): ?AccountData
{
$user = User::where('email', $email)->first();
if (! $user) {
return null;
}
return new SimpleAccountData(
id: (string) $user->id,
name: $user->name,
email: $user->email,
phone: $user->phone,
avatarUrl: $user->avatar_url,
metadata: ['role' => $user->role],
);
}
public function exists(string $email): bool
{
return User::where('email', $email)->exists();
}
public function registrationFields(): array
{
return [
new RegistrationField(
name: 'name',
label: 'Full name',
type: 'text',
required: true,
rules: ['string', 'max:255'],
),
new RegistrationField(
name: 'phone',
label: 'Phone number',
type: 'tel',
required: false,
rules: ['string', 'max:20'],
),
];
}
public function create(string $email, array $data): AccountData
{
$user = User::create([
'email' => $email,
'name' => $data['name'],
'phone' => $data['phone'] ?? null,
]);
return new SimpleAccountData(
id: (string) $user->id,
name: $user->name,
email: $user->email,
phone: $user->phone,
);
}
}
Then enable registration in config:
'accounts' => [
'driver' => 'database',
'drivers' => [
'database' => \App\Frontdoor\DatabaseAccountDriver::class,
],
],
'registration' => [
'enabled' => true,
],
When a user tries to log in with an email that doesn't exist, they'll see: "No account found. Would you like to create one?" After clicking "Create account", a verification OTP is sent to confirm email ownership. Once verified, a registration form is displayed with the fields defined by registrationFields(). The submitted data is validated against each field's rules, then passed to create(). The user is automatically logged in and a welcome email is sent.
The RegistrationField value object supports these field types:
| Type | HTML Element | Notes |
|---|---|---|
text |
<input type="text"> |
Default type |
email |
<input type="email"> |
|
tel |
<input type="tel"> |
|
textarea |
<textarea> |
|
select |
<select> |
Requires options array |
checkbox |
<input type="checkbox"> |
Example with all field types:
public function registrationFields(): array
{
return [
new RegistrationField(
name: 'name',
label: 'Full name',
required: true,
rules: ['string', 'max:255'],
),
new RegistrationField(
name: 'department',
label: 'Department',
type: 'select',
required: true,
rules: ['string', 'in:engineering,marketing,sales'],
options: [
'engineering' => 'Engineering',
'marketing' => 'Marketing',
'sales' => 'Sales',
],
),
new RegistrationField(
name: 'agree_terms',
label: 'I agree to the terms of service',
type: 'checkbox',
required: true,
rules: ['accepted'],
),
];
}
If your driver needs constructor arguments that can't be auto-resolved, bind it in the container:
// In AppServiceProvider
$this->app->bind(ApiAccountDriver::class, function ($app) {
return new ApiAccountDriver(
apiUrl: config('services.user_api.url'),
apiKey: config('services.user_api.key'),
);
});
Then reference it in config using either approach:
// Named driver
'driver' => 'api',
'drivers' => ['api' => \App\Frontdoor\ApiAccountDriver::class],
// Or FQCN
'driver' => \App\Frontdoor\ApiAccountDriver::class,
Drivers return AccountData objects. In most cases, use the built-in SimpleAccountData DTO:
new SimpleAccountData(
id: '1',
name: 'Jane Doe',
email: 'jane@example.com',
phone: '+1-555-0100', // optional
avatarUrl: null, // optional, falls back to generated gradient
metadata: ['role' => 'admin'], // optional
);
If you need custom behavior (computed names, Gravatar URLs, etc.), implement the AccountData interface directly. See the interface for the full list of required methods: getId(), getName(), getEmail(), getPhone(), getAvatarUrl(), getMetadata(), getInitial(), toArray().
Registration allows users to create accounts through the login flow. When enabled, users who attempt to log in with an email that doesn't exist will see an option to create an account.
findByEmail() returns null (account doesn't exist)registrationFields() method (email is locked)create() is called with the email and form dataAccountRegistered event is dispatchedFor registration to work, both of these conditions must be met:
registration.enabled must be true in configCreatableAccountDriver (not just AccountDriver)If either condition is false, the registration prompt will not appear and users cannot self-register.
'registration' => [
'enabled' => false, // Set to true to enable registration
],
| Driver | Supports Registration | Notes |
|---|---|---|
testing (built-in) |
Yes | For development only |
Your class implementing AccountDriver |
No | Sign-in only |
Your class implementing CreatableAccountDriver |
Yes | Sign-in + registration |
Registration uses three configurable mailables:
'mail' => [
// Standard login OTP
'mailable' => \Daikazu\LaravelFrontdoor\Mail\OtpMail::class,
'subject' => 'Your login code',
// Email verification OTP (sent before registration form)
'verification_mailable' => \Daikazu\LaravelFrontdoor\Mail\OtpMail::class,
'verification_subject' => 'Verify your email address',
// Welcome email (sent after account creation, no OTP)
'welcome_mailable' => \Daikazu\LaravelFrontdoor\Mail\WelcomeMail::class,
'welcome_subject' => 'Welcome to ' . env('APP_NAME', 'our app'),
'from' => [
'address' => env('FRONTDOOR_MAIL_FROM', env('MAIL_FROM_ADDRESS')),
'name' => env('FRONTDOOR_MAIL_FROM_NAME', env('MAIL_FROM_NAME')),
],
],
The verification mailable must implement OtpMailable. The welcome mailable is a plain Mailable that receives the account data via setAccount() — it does not contain an OTP code.
Listen for the AccountRegistered event to perform post-registration actions:
use Daikazu\LaravelFrontdoor\Events\AccountRegistered;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
Event::listen(function (AccountRegistered $event) {
Log::info("New user registered: {$event->account->getEmail()}");
// Send to analytics, assign default roles, create onboarding tasks, etc.
});
To restrict who can register (e.g., only company emails), add validation rules in your driver's registrationFields() or validation logic in your create() method.
The nav-login component provides a complete authentication UI:
<x-frontdoor::nav-login />
When not authenticated: Displays a login button that opens the OTP flow (modal or page, depending on ui.mode).
When authenticated: Displays a user dropdown with:
<x-frontdoor::nav-login
label="Sign In" {{-- Custom login button text --}}
:account-route="route('profile')" {{-- Link to account page --}}
size="lg" {{-- Avatar size: sm, md, lg --}}
/>
Control the authentication experience via ui.mode:
Modal mode (default):
'ui' => [
'mode' => 'modal', // Opens login flow in an overlay
],
Page mode:
'ui' => [
'mode' => 'page', // Redirects to full-page login form
],
Use the frontdoor guard in middleware:
Route::middleware('auth:frontdoor')->group(function () {
Route::get('/dashboard', function () {
$user = auth('frontdoor')->user();
return view('dashboard', ['user' => $user]);
});
});
Check authentication in Blade:
@auth('frontdoor')
<p>Welcome, {{ auth('frontdoor')->user()->name }}!</p>
@endauth
@guest('frontdoor')
<p>Please log in to continue.</p>
@endguest
The authenticated user exposes account data as properties:
$user = auth('frontdoor')->user();
$user->id; // Unique identifier
$user->name; // Display name
$user->email; // Email address
$user->phone; // Phone number (or null)
$user->avatar_url; // Avatar URL (or null)
$user->initial; // First letter of name
$user->metadata; // Full metadata array
Metadata keys are accessible directly as properties too. For example, if your driver returns metadata: ['role' => 'admin', 'company' => 'Acme']:
$user->role; // 'admin'
$user->company; // 'Acme'
$user->unknown_key; // null (key not in metadata)
The Frontdoor facade provides programmatic access to all authentication features:
use Daikazu\LaravelFrontdoor\Facades\Frontdoor;
requestOtp(string $email): stringRequest an OTP code for an email address. Generates a code, emails it to the user, and returns the code.
$code = Frontdoor::requestOtp('user@example.com');
// Email is sent with 6-digit code
Throws AccountNotFoundException if the account doesn't exist and registration is disabled.
verify(string $email, string $code): boolVerify an OTP code and log the user in.
$success = Frontdoor::verify('user@example.com', '123456');
if ($success) {
// User is now authenticated
$user = auth('frontdoor')->user();
}
Returns false if the code is invalid or expired.
loginAs(string $email): boolLog in a user directly without requiring an OTP. Useful for testing or admin impersonation.
Frontdoor::loginAs('user@example.com');
// User is now authenticated
Returns false if the account doesn't exist.
registrationFields(): RegistrationField[]Get the registration form fields defined by the active account driver.
$fields = Frontdoor::registrationFields();
foreach ($fields as $field) {
echo $field->name; // e.g. 'name'
echo $field->label; // e.g. 'Full name'
echo $field->type; // e.g. 'text'
echo $field->required; // e.g. true
}
Throws RegistrationNotSupportedException if registration is not enabled or the driver doesn't support it.
requestEmailVerification(string $email): stringSend a verification OTP to an email address before registration. Used to confirm email ownership before showing the registration form.
$code = Frontdoor::requestEmailVerification('newuser@example.com');
// Verification email sent with 6-digit code
If the email already exists, falls through to requestOtp() silently (prevents email enumeration). Throws RegistrationNotSupportedException if registration is not enabled.
verifyEmailOnly(string $email, string $code): boolVerify an OTP code without logging in the user. Used during registration to confirm email ownership.
$verified = Frontdoor::verifyEmailOnly('newuser@example.com', '123456');
if ($verified) {
// Email is verified, show registration form
}
Returns false if the code is invalid or expired. Does not log in the user.
register(string $email, array $data = []): AccountDataCreate a new account, auto-login the user, and send a welcome email. Validates $data against the rules defined by the driver's registrationFields(). Only works if registration is enabled and the driver supports it.
try {
$account = Frontdoor::register('newuser@example.com', [
'name' => 'New User',
]);
// User is now logged in, welcome email sent
echo $account->getName(); // 'New User'
} catch (\Illuminate\Validation\ValidationException $e) {
// Required fields missing or invalid
} catch (\Daikazu\LaravelFrontdoor\Exceptions\RegistrationNotSupportedException $e) {
// Registration not enabled or driver doesn't support it
}
If the email already exists, falls through to requestEmailVerification() silently (prevents email enumeration).
registrationEnabled(): boolCheck if registration is enabled and supported by the current driver.
if (Frontdoor::registrationEnabled()) {
// Show registration UI
}
accounts(): AccountManagerAccess the account manager to interact with drivers.
$manager = Frontdoor::accounts();
// Find an account
$account = $manager->driver()->findByEmail('user@example.com');
// Check if account exists
$exists = $manager->driver()->exists('user@example.com');
otp(): OtpManagerAccess the OTP manager for low-level OTP operations.
$otpManager = Frontdoor::otp();
// Generate a code
$code = $otpManager->generate('user@example.com');
// Verify a code
$valid = $otpManager->verify('user@example.com', '123456');
// Delete a code
$otpManager->delete('user@example.com');
Complete configuration options in config/frontdoor.php:
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Guard
|--------------------------------------------------------------------------
|
| The guard name used for Frontdoor authentication. This is registered
| automatically and used for all authentication checks.
|
*/
'guard' => 'frontdoor',
/*
|--------------------------------------------------------------------------
| Account Provider
|--------------------------------------------------------------------------
|
| The driver determines where user accounts are looked up.
|
| Built-in:
| 'testing' - Cache-backed driver with seed users. Supports registration.
| For development and trying out the package.
|
| Custom drivers can be registered two ways:
|
| 1. Named driver — add an entry to the drivers array:
| 'driver' => 'salesforce',
| 'drivers' => ['salesforce' => \App\Frontdoor\SalesforceProvider::class]
|
| 2. FQCN — set driver directly to the class name:
| 'driver' => \App\Frontdoor\SalesforceProvider::class
|
| The class must implement AccountDriver (sign-in only)
| or CreatableAccountDriver (sign-in + registration).
|
*/
'accounts' => [
'driver' => env('FRONTDOOR_ACCOUNT_DRIVER', 'testing'),
'drivers' => [
'testing' => [
'users' => [
// Seed users for the testing driver.
// 'email@example.com' => [
// 'name' => 'User Name',
// 'phone' => '+1-555-0100', // optional
// 'metadata' => ['role' => 'admin'], // optional
// ],
],
],
],
],
/*
|--------------------------------------------------------------------------
| Registration
|--------------------------------------------------------------------------
|
| When enabled, users who attempt to log in without an existing account
| will be offered the option to create one. The active account driver
| must implement CreatableAccountDriver for this to work.
|
*/
'registration' => [
'enabled' => false,
],
/*
|--------------------------------------------------------------------------
| OTP Settings
|--------------------------------------------------------------------------
|
| Configure one-time password generation and validation behavior.
|
*/
'otp' => [
'length' => 6, // Number of digits in the code
'ttl' => 600, // Time-to-live in seconds (10 minutes)
'cache_store' => null, // Cache store (null = default)
'cache_prefix' => 'frontdoor:otp:',
'rate_limit' => [
'max_attempts' => 5, // Max OTP requests per window
'decay_seconds' => 300, // Rate limit window (5 minutes)
],
],
/*
|--------------------------------------------------------------------------
| Mail Settings
|--------------------------------------------------------------------------
|
| Configure email delivery. Separate mailables for login OTP,
| registration verification OTP, and post-registration welcome.
|
*/
'mail' => [
'mailable' => \Daikazu\LaravelFrontdoor\Mail\OtpMail::class,
'from' => [
'address' => env('FRONTDOOR_MAIL_FROM', env('MAIL_FROM_ADDRESS')),
'name' => env('FRONTDOOR_MAIL_FROM_NAME', env('MAIL_FROM_NAME')),
],
'subject' => 'Your login code',
// Email verification OTP (sent before registration form)
'verification_mailable' => \Daikazu\LaravelFrontdoor\Mail\OtpMail::class,
'verification_subject' => 'Verify your email address',
// Welcome email (sent after account creation, no OTP)
'welcome_mailable' => \Daikazu\LaravelFrontdoor\Mail\WelcomeMail::class,
'welcome_subject' => 'Welcome to ' . env('APP_NAME', 'our app'),
],
/*
|--------------------------------------------------------------------------
| UI Settings
|--------------------------------------------------------------------------
|
| Control the authentication UI behavior and appearance.
|
*/
'ui' => [
'mode' => 'modal', // 'modal' (overlay) or 'page' (redirect)
'prefer_livewire' => true, // Use Livewire when available
'login_route' => '/login', // Fallback login route
'nav' => [
'login_label' => 'Login',
'account_label' => 'Account',
'logout_label' => 'Logout',
'account_route' => null, // Optional account page route
],
],
/*
|--------------------------------------------------------------------------
| Avatar Settings
|--------------------------------------------------------------------------
|
| Configure deterministic avatar generation. Avatars are generated from
| email hashes using HSL gradients.
|
*/
'avatar' => [
'algorithm' => 'gradient', // Avatar generation algorithm
'saturation' => 65, // HSL saturation percentage
'lightness' => 55, // HSL lightness percentage
],
/*
|--------------------------------------------------------------------------
| Routes
|--------------------------------------------------------------------------
|
| Configure authentication routes. Set enabled to false to disable
| automatic route registration.
|
*/
'routes' => [
'enabled' => true,
'prefix' => 'frontdoor',
'middleware' => ['web'],
],
];
Laravel Frontdoor dispatches events throughout the authentication and registration flow:
| Event | Description | Properties |
|---|---|---|
OtpRequested |
User requested an OTP code | email |
OtpVerified |
OTP code successfully verified | email |
LoginSucceeded |
User successfully logged in | identity (AccountData) |
LoginFailed |
Login attempt failed | email, reason |
LogoutSucceeded |
User logged out | identity (AccountData) |
AccountRegistered |
New account created via registration | account (AccountData) |
Register listeners in EventServiceProvider:
use Daikazu\LaravelFrontdoor\Events\LoginSucceeded;
use Daikazu\LaravelFrontdoor\Events\AccountRegistered;
protected $listen = [
LoginSucceeded::class => [
LogUserLogin::class,
],
AccountRegistered::class => [
SendWelcomeNotification::class,
],
];
Or use closures in AppServiceProvider:
use Daikazu\LaravelFrontdoor\Events\OtpRequested;
use Daikazu\LaravelFrontdoor\Events\AccountRegistered;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
Event::listen(function (OtpRequested $event) {
Log::info("OTP requested for {$event->email}");
});
Event::listen(function (AccountRegistered $event) {
Log::info("New account created: {$event->account->getEmail()}");
// Send to analytics, create welcome tasks, etc.
});
Customize the UI by publishing the views:
php artisan vendor:publish --tag="laravel-frontdoor-views"
Views will be published to resources/views/vendor/frontdoor/.
Available views:
components/nav-login.blade.php - Navigation login componentlivewire/login-flow.blade.php - Livewire login flowlivewire/register-fields.blade.php - Dynamic registration form fields partialblade/login.blade.php - Blade fallback login pageblade/register.blade.php - Blade email verification prompt pageblade/register-complete.blade.php - Blade registration form pageblade/verify.blade.php - Blade OTP verification pagemail/otp.blade.php - OTP email template (login and verification)mail/welcome.blade.php - Welcome email template (post-registration, no OTP)Create your own mailable implementing the OtpMailable contract. The contract requires three setter methods:
<?php
namespace App\Mail;
use Daikazu\LaravelFrontdoor\Contracts\AccountData;
use Daikazu\LaravelFrontdoor\Contracts\OtpMailable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class BrandedOtpMail extends Mailable implements OtpMailable
{
public string $code = '';
public ?AccountData $account = null;
public int $expiresInMinutes = 10;
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function setAccount(AccountData $account): static
{
$this->account = $account;
return $this;
}
public function setExpiresInMinutes(int $minutes): static
{
$this->expiresInMinutes = $minutes;
return $this;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your Login Code');
}
public function content(): Content
{
return new Content(
view: 'emails.branded-otp',
with: [
'code' => $this->code,
'account' => $this->account,
'expiresInMinutes' => $this->expiresInMinutes,
],
);
}
}
Update the configuration:
'mail' => [
'mailable' => \App\Mail\BrandedOtpMail::class,
// ...
],
Return a custom avatar URL from your AccountData implementation:
public function getAvatarUrl(): ?string
{
// Use Gravatar
$hash = md5(strtolower(trim($this->email)));
return "https://www.gravatar.com/avatar/{$hash}?d=mp&s=200";
// Or use UI Avatars
return "https://ui-avatars.com/api/?name=" . urlencode($this->name) . "&background=random";
// Or return stored avatar path
return $this->avatarPath ? asset($this->avatarPath) : null;
}
If getAvatarUrl() returns null, Frontdoor will generate a deterministic gradient avatar based on the email hash.
Modify gradient avatar settings:
'avatar' => [
'algorithm' => 'gradient',
'saturation' => 75, // Higher saturation = more vivid colors
'lightness' => 50, // Lower lightness = darker colors
],
Run the test suite:
composer test
Run a specific test:
composer test -- --filter=OtpFlowTest
Run tests with coverage:
composer test-coverage
Run static analysis:
composer analyse
Format code:
composer format
Use loginAs() to bypass OTP verification in tests:
use Daikazu\LaravelFrontdoor\Facades\Frontdoor;
it('allows authenticated users to view dashboard', function () {
Frontdoor::loginAs('user@example.com');
$this->get('/dashboard')
->assertOk()
->assertSee('Dashboard');
});
Use Mail and Cache fakes to test OTP flow:
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Cache;
it('sends OTP email when user requests code', function () {
Mail::fake();
Frontdoor::requestOtp('user@example.com');
Mail::assertSent(OtpMail::class, function ($mail) {
return $mail->hasTo('user@example.com');
});
});
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.
How can I help you explore Laravel packages today?