joe-404/laravel-auth
Config-driven, drop-in auth for Laravel 12/13: JSON API for registration with OTP/magic-link verification, login, refresh tokens, password reset, Google OAuth, multi-session/device fingerprinting, long-lived API tokens, account status workflows, and referrals.
Everything you can override in joe-404/laravel-auth without touching the package source.
Add any field to the registration form — no controller changes required.
Fields declared in extra_fields_rules are validated on POST /auth/register. Their validated values are held in cache and written to User::create() during POST /auth/register/complete (step 3).
Applies to social sign-in too (v2.6). The same
extra_fields_rulesdrive the OAuth path whensocial.profile_completion.enabledis true: a brand-new Google user is sent toPOST /auth/social/complete, which validates these exact rules before creating the account. Required fields block; optional fields are validated only if submitted. So you declare your fields once and both registration paths enforce them. Seedocs/configuration.md→socialand the v2.6 section ofdocs/upgrading.md.
// config/auth_system.php
'registration' => [
'extra_fields_rules' => [
'username' => 'required|string|min:3|max:30|unique:users,username',
'date_of_birth' => 'required|date|before:18 years ago',
'agreed_terms' => 'required|accepted',
'agreed_18_plus' => 'required|accepted',
'phone' => 'nullable|string|max:20',
],
],
Rules can be a pipe-separated string (as above) or an array (required for object-based rules):
'extra_fields_rules' => [
'username' => ['required', 'string', 'min:3', Rule::unique('users', 'username')],
],
You must also:
User model's $fillable:protected $fillable = [
'name', 'email', 'password',
'username', 'date_of_birth', // ← add your fields here
];
$table->string('username')->nullable()->unique();
$table->date('date_of_birth')->nullable();
Fields not in $fillable are silently ignored by User::create().
For complex conditional rules, or when extra_fields_rules is not flexible enough, extend the package's RegisterRequest:
// app/Http/Requests/MyRegisterRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Validation\Rule;
use Joe404\LaravelAuth\Http\Requests\RegisterRequest;
class MyRegisterRequest extends RegisterRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'username' => ['required', 'string', 'min:3', Rule::unique('users')],
'phone' => ['required_if:country,LB', 'string', 'max:20'],
]);
}
public function messages(): array
{
return array_merge(parent::messages(), [
'username.unique' => 'That username is already taken.',
'phone.required_if' => 'Phone number is required for users in Lebanon.',
]);
}
}
Wire it in config:
'registration' => [
'request_class' => \App\Http\Requests\MyRegisterRequest::class,
],
request_class takes priority over extra_fields_rules — if both are set, the custom request class is used and extra_fields_rules is ignored.
Override validation error messages per field/rule without writing a custom request class.
// config/auth_system.php
'registration' => [
'extra_fields_rules' => [
'username' => 'required|string|min:3|alpha_dash',
'date_of_birth' => 'required|date|before:18 years ago',
'agreed_terms' => 'required|accepted',
'agreed_18_plus' => 'required|accepted',
],
'extra_fields_messages' => [
'username.required' => 'Please choose a username.',
'username.min' => 'Username must be at least 3 characters.',
'username.alpha_dash' => 'Usernames may only contain letters, numbers, dashes, and underscores.',
'date_of_birth.required' => 'Please enter your date of birth.',
'date_of_birth.before' => 'You must be at least 18 years old to register.',
'agreed_terms.accepted' => 'You must accept our Terms of Service to continue.',
'agreed_18_plus.accepted'=> 'You must confirm that you are 18 or older.',
],
],
Format: "field.rule" => "message". Any key not listed falls back to Laravel's built-in message.
Error response shape (HTTP 422):
{
"success": false,
"message": "The given data was invalid.",
"errors": {
"username": ["Please choose a username."],
"agreed_terms": ["You must accept our Terms of Service to continue."]
}
}
Derive or normalise a column value from the validated registration data — without writing a custom controller.
Contract:
namespace Joe404\LaravelAuth\Contracts;
interface ExtraFieldTransformerContract
{
public function transform(array $validated): mixed;
}
$validated contains all validated input: email plus every key from extra_fields_rules.
Step 1 — Create the transformer class:
// app/Transformers/UsernameNormalizer.php
<?php
namespace App\Transformers;
use Joe404\LaravelAuth\Contracts\ExtraFieldTransformerContract;
final class UsernameNormalizer implements ExtraFieldTransformerContract
{
public function transform(array $validated): mixed
{
return strtolower(trim((string) ($validated['username'] ?? '')));
}
}
Step 2 — Register it in config:
The array key is the target column name where the result is written.
'registration' => [
'extra_fields_transformers' => [
'username_normalized' => \App\Transformers\UsernameNormalizer::class,
],
],
Step 3 — Add the target column to your migration and $fillable:
// Migration
$table->string('username_normalized')->nullable()->unique();
// User.php
protected $fillable = [..., 'username_normalized'];
Another example — derive a display_name from first_name + last_name:
final class DisplayNameTransformer implements ExtraFieldTransformerContract
{
public function transform(array $validated): mixed
{
$first = trim($validated['first_name'] ?? '');
$last = trim($validated['last_name'] ?? '');
return trim("{$first} {$last}") ?: null;
}
}
'extra_fields_transformers' => [
'display_name' => \App\Transformers\DisplayNameTransformer::class,
],
Security: transformers cannot bypass the privileged-field denylist. These target names are always stripped even if a transformer writes to them: role, roles, is_admin, admin, email_verified_at, password, password_change_required.
Generate a unique referral code for every new user during finalizeRegistration().
Looking for the full referral system — fingerprint anti-abuse, reward handlers, redeem endpoint, admin override, frontend integration guide? See docs/referral-codes.md for the complete walkthrough. This section only covers the generator side.
'referral_code' => [
'enabled' => true,
'column' => 'referral_code',
'length' => 8,
'uppercase' => true,
'generator' => null, // null = built-in random alphanumeric
],
Schema::table('users', function (Blueprint $table): void {
$table->string('referral_code', 20)->nullable()->unique();
});
Add referral_code to User model's $fillable.
If the user already supplied a value for the referral column through extra_fields_rules, the package will not overwrite it.
See Custom Referral Code Generator below.
Swap the JSON envelope to match your API conventions.
The default envelope:
// success
{ "success": true, "message": "...", "data": {} }
// error
{ "success": false, "message": "...", "errors": {} }
Contract:
namespace Joe404\LaravelAuth\Contracts;
interface ResponseFormatterContract
{
public function format(bool $success, string $message, array $data, array $errors): array;
}
Example — custom envelope:
// app/Auth/MyResponseFormatter.php
<?php
namespace App\Auth;
use Joe404\LaravelAuth\Contracts\ResponseFormatterContract;
final class MyResponseFormatter implements ResponseFormatterContract
{
public function format(bool $success, string $message, array $data, array $errors): array
{
return [
'ok' => $success,
'msg' => $message,
'payload' => $success ? $data : $errors,
];
}
}
Register via config (recommended):
// config/auth_system.php
'response' => [
'formatter' => \App\Auth\MyResponseFormatter::class,
],
Or via service container (config takes priority):
// app/Providers/AppServiceProvider.php
use Joe404\LaravelAuth\Contracts\ResponseFormatterContract;
public function register(): void
{
$this->app->bind(ResponseFormatterContract::class, \App\Auth\MyResponseFormatter::class);
}
Replace the built-in email delivery with any channel — SMS, WhatsApp, push notification, etc.
Use when your channel sends either the OTP code or the magic link, but not both at once:
namespace Joe404\LaravelAuth\Contracts;
interface OtpChannelContract
{
public function send(string $recipient, string $code, string $type, array $context = []): void;
}
| Parameter | Description |
|---|---|
$recipient |
Email address (or phone number if your channel uses phone) |
$code |
The OTP digits (e.g. "482910") for type=otp; the full URL for type=magic_link |
$type |
email_verify, magic_link_verify, password_reset, magic_link_reset |
$context |
Extra metadata (user ID, locale, etc.) |
Example — Twilio SMS:
// app/Channels/SmsOtpChannel.php
<?php
namespace App\Channels;
use Joe404\LaravelAuth\Contracts\OtpChannelContract;
use Twilio\Rest\Client;
final class SmsOtpChannel implements OtpChannelContract
{
public function __construct(private readonly Client $twilio) {}
public function send(string $recipient, string $code, string $type, array $context = []): void
{
// For magic link types, $code contains the full URL
$message = str_contains($type, 'magic_link')
? "Click to verify: {$code}"
: "Your code: {$code}";
$this->twilio->messages->create($recipient, [
'from' => config('services.twilio.from'),
'body' => $message,
]);
}
}
Use when your channel can send OTP code and magic link in a single message (e.g. a WhatsApp template that includes a button):
namespace Joe404\LaravelAuth\Contracts;
interface CombinedOtpChannelContract extends OtpChannelContract
{
public function sendCombined(string $recipient, string $code, string $url, string $type, array $context = []): void;
}
When verification.method=both and your channel implements CombinedOtpChannelContract, the package calls sendCombined() instead of calling send() twice. This lets you send a single message that contains both the code and the link.
final class WhatsAppChannel implements CombinedOtpChannelContract
{
public function send(string $recipient, string $code, string $type, array $context = []): void
{
// Fallback: code-only (called when type is not "both")
WhatsApp::sendText($recipient, "Your code: {$code}");
}
public function sendCombined(string $recipient, string $code, string $url, string $type, array $context = []): void
{
// Single message with code + clickable link
WhatsApp::sendTemplate($recipient, 'auth_verify', [
'code' => $code,
'url' => $url,
]);
}
}
Register in config:
'otp_channel' => [
'driver' => \App\Channels\SmsOtpChannel::class,
],
Two options — pick one per email, or mix.
php artisan vendor:publish --tag=auth-views
Edit files in resources/views/vendor/laravel-auth/emails/:
| Template | |
|---|---|
otp-verify.blade.php |
OTP code for registration |
otp-reset.blade.php |
OTP code for password reset |
magic-link-verify.blade.php |
Magic link for registration |
magic-link-reset.blade.php |
Magic link for password reset |
otp-verify-combined.blade.php |
OTP + link for registration (method=both) |
otp-reset-combined.blade.php |
OTP + link for password reset (method=both) |
Variables available in templates: $code, $url (magic link only), $type, $user.
Point any mail.* config key to your own Notification class.
Constructor signatures:
__construct(string $code, string $type, array $context)__construct(string $code, string $url, string $type, array $context)// app/Notifications/MyVerificationEmail.php
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class MyVerificationEmail extends Notification
{
public function __construct(
private readonly string $code,
private readonly string $type,
private readonly array $context = [],
) {}
public function via(mixed $notifiable): array
{
return ['mail'];
}
public function toMail(mixed $notifiable): MailMessage
{
return (new MailMessage)
->subject('Verify your email — Acme')
->line("Your code is: **{$this->code}**")
->line('This code expires in 10 minutes.');
}
}
// config/auth_system.php
'mail' => [
'otp_verify_notification' => \App\Notifications\MyVerificationEmail::class,
],
Option B takes priority over Option A for the same email slot. You can mix — override only the emails you care about and leave the rest using the built-in Blade template.
Same pattern for account lifecycle notifications:
| Config key | Sent when |
|---|---|
account_deleted_notification |
User deletes their account |
account_restored_notification |
Account auto-restored on login during grace |
account_purged_notification |
Purge worker permanently anonymises the account |
account_status_changed_notification |
Admin changes the user's status |
account_deactivated_notification |
User deactivates their account |
account_reactivated_notification |
Account auto-reactivated on login |
Toggle individual emails without removing the class:
'mail' => [
'account_notifications_enabled' => [
'deleted' => true,
'restored' => true,
'purged' => false,
'status_changed' => false,
'deactivated' => true,
'reactivated' => true,
],
],
Override any success or error message with a static string.
// config/auth_system.php
'messages' => [
'register_initiated' => 'Almost there! Check your inbox for a verification code.',
'register_complete' => 'Welcome to Acme!',
'login_success' => 'Welcome back.',
'logout_success' => null, // null = keep the built-in default
],
'errors' => [
'invalid_credentials' => 'That email or password is incorrect.',
'account_locked' => 'Account locked. Try again in :seconds seconds.',
],
Setting a key to null or '' re-enables the translation pipeline for that key. See docs/localization.md for the full key list and multi-language support.
The package ships built-in English and Arabic translations. Every user-facing string goes through a three-step resolver:
config('auth_system.messages.<key>') / config('auth_system.errors.<key>') — wins if non-nulltrans('auth_system::messages.<key>') / trans('auth_system::errors.<key>') — per-localePublish the language files:
php artisan vendor:publish --tag=auth-lang
Files appear at lang/vendor/auth_system/en/messages.php and errors.php.
Add a new locale (e.g. French):
lang/vendor/auth_system/fr/messages.php
lang/vendor/auth_system/fr/errors.php
Set locale per request:
// app/Http/Middleware/SetLocaleFromHeader.php
app()->setLocale($request->header('Accept-Language', 'en'));
See docs/localization.md for the complete guide, full key list, and placeholders.
Replace the built-in random alphanumeric generator with your own.
Contract:
namespace Joe404\LaravelAuth\Contracts;
interface ReferralCodeGeneratorContract
{
public function generate(): string;
}
Example — word-list style codes:
// app/Auth/HumanReferralGenerator.php
<?php
namespace App\Auth;
use Illuminate\Support\Str;
use Joe404\LaravelAuth\Contracts\ReferralCodeGeneratorContract;
final class HumanReferralGenerator implements ReferralCodeGeneratorContract
{
public function generate(): string
{
// e.g. "BRAVE-WHALE-7423"
return strtoupper(Str::slug(fake()->words(2, true)) . '-' . rand(1000, 9999));
}
}
Register in config:
'referral_code' => [
'enabled' => true,
'generator' => \App\Auth\HumanReferralGenerator::class,
],
The package binds the generator to the container and resolves it via app()->make($fqcn), so it supports constructor injection.
The phone verification + SMS-2FA system delivers codes through a driver chosen per channel (sms, voice, whatsapp). Built-in drivers: log (dev), infobip, messagecentral, twilio, firebase. To use any other provider (Vonage, Plivo, AWS SNS, an on-prem SMS gateway…), write a driver and register it.
<?php
namespace App\Phone;
use Joe404\LaravelAuth\Contracts\PhoneDriverContract;
use Joe404\LaravelAuth\Exceptions\PhoneVerificationException;
use Illuminate\Support\Facades\Http;
class VonageDriver implements PhoneDriverContract
{
/** [@param](https://github.com/param) array<string,mixed> $config The provider's config block. */
public function __construct(private readonly array $config) {}
public function send(string $phone, string $code, string $channel, array $context = []): void
{
$response = Http::asForm()->post('https://rest.nexmo.com/sms/json', [
'api_key' => $this->config['api_key'] ?? '',
'api_secret' => $this->config['api_secret'] ?? '',
'to' => ltrim($phone, '+'),
'from' => $this->config['from'] ?? 'MyApp',
'text' => "Your verification code is: {$code}",
]);
// Throw PhoneVerificationException on failure so the channel's
// fallback provider (if configured) takes over.
if (! $response->successful()) {
throw new PhoneVerificationException(
"Vonage send failed: HTTP {$response->status()}",
'phone_send_failed',
);
}
}
/** Channels this driver can deliver. A channel not listed here triggers a config error. */
public function supports(): array
{
return ['sms']; // add 'voice' / 'whatsapp' only if you implement them
}
public function name(): string
{
return 'vonage';
}
}
// config/auth_system.php → phone.providers
'vonage' => [
'driver' => \App\Phone\VonageDriver::class,
'api_key' => env('VONAGE_API_KEY'),
'api_secret' => env('VONAGE_API_SECRET'),
'from' => env('VONAGE_FROM', 'MyApp'),
],
The whole provider block is passed to your driver's constructor as $config, so any keys you add are available as $this->config['…'].
// config/auth_system.php → phone.channels
'sms' => ['provider' => 'vonage', 'fallback' => 'log'],
That's it — no service-provider code needed. The package's PhoneDriverManager resolves the driver class from the container (so you can type-hint dependencies in the constructor alongside $config).
If you need to build the driver yourself (custom client, secrets manager, etc.), call PhoneDriverManager::extend() in your AppServiceProvider::boot():
use Joe404\LaravelAuth\Phone\PhoneDriverManager;
public function boot(): void
{
$this->app->make(PhoneDriverManager::class)
->extend('vonage', function ($app, array $config) {
return new \App\Phone\VonageDriver($config);
});
}
A closure registered this way wins over the `dr...
How can I help you explore Laravel packages today?