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.
The package fires a Laravel event at every significant point in the auth lifecycle. Your host app subscribes to the events it cares about and runs its own logic — wallet seeding, audit rows, welcome emails, analytics, webhooks — without modifying the package or forking controllers.
A typical config-driven library asks you to set a callback:
'on_user_registered' => \App\Hooks\MyHook::class, // not how this package works
That works for one hook per slot. The moment a second team needs the same trigger (auditing, billing, messaging), you're wrapping classes inside classes.
Events solve this with N:M wiring. The package fires one event; any number of independent listeners — yours, a teammate's, a third-party package's — can react without knowing about each other.
All events live under the Joe404\LaravelAuth\Events\ namespace.
| Event | When fired | Payload |
|---|---|---|
RegistrationEmailVerified |
After OTP / magic link verification, BEFORE the user row exists. Broadcasts on private-auth.verification.{tempToken} (when Reverb is enabled) so SPA tabs can drive cross-tab handoff. |
$tempToken, $completionToken, $email |
EmailVerified |
After POST /auth/register/complete — user row exists, role assigned, transaction committed |
$user, $tempToken |
UserLoggedIn |
Successful login (password or social) | $user, $request |
UserLoggedOut |
Any logout (single session or logout/all) |
— |
PasswordChanged |
Password reset confirmation or authenticated password change | $user |
SuspiciousLoginDetected |
Login from a device the package hasn't seen before for this user | $user, $ip, $browser, $os, $city, $country |
| Event | When fired | Payload |
|---|---|---|
AccountStatusChanged |
Any status change (admin, user, or automatic) | $user, $from, $to, $reason, $expiresAt |
AccountDeleted |
User calls DELETE /auth/account |
$user, $gracePeriodDays, $scheduledPurgeAt |
AccountRestored |
Login auto-restore during grace period | $user |
AccountPurged |
Purge worker permanently anonymises the row after grace | $userId, $email (from snapshot) |
| Event | When fired | Payload |
|---|---|---|
PhoneVerified |
A user's phone OTP is verified and phone_verified_at is stamped |
$user, $phone |
TwoFactorEnrolled |
A 2FA method is verified and activated for a user | $user, $method (totp/email/sms) |
TwoFactorDisabled |
A 2FA method is removed | $user, $method |
TwoFactorChallengeIssued |
Login (or auth.2fa step-up) issues/reuses a challenge for an enrolled user |
$user, $challengeToken, $method, $reused |
TwoFactorVerified |
A 2FA challenge is completed successfully (login finishes) | $user, $method |
TwoFactorChallengeFailed |
A wrong code is submitted against an active challenge | $user, $method, $attempts |
TrustedDeviceAdded |
A device becomes trusted (registration auto-trust, or user opt-in at challenge) | $user, $deviceId, $level |
TrustedDeviceRevoked |
A trusted device is revoked (single or revoke-all) | $user, $deviceId, $reason |
UserLoggedIntiming (v2.6): when a user with 2FA enrolled submits correct credentials,UserLoggedInstill fires at that point (credential success) — even though no token is issued until the challenge is completed.TwoFactorChallengeIssuedfires immediately after, andTwoFactorVerifiedfires once the challenge passes. Listeners that audited "user passed the password check" keep working unchanged.
Event payloads are public properties on the event class:
use Joe404\LaravelAuth\Events\EmailVerified;
use Joe404\LaravelAuth\Events\AccountStatusChanged;
use Joe404\LaravelAuth\Events\UserLoggedIn;
// RegistrationEmailVerified
$event->tempToken // string — the UUID returned from POST /auth/register
$event->completionToken // string — the UUID the SPA must send to /auth/register/complete
$event->email // string — the verified address
// EmailVerified
$event->user // App\Models\User instance
$event->tempToken // string — the UUID from step 1 of registration
// UserLoggedIn
$event->user // User
$event->request // Illuminate\Http\Request
// AccountStatusChanged
$event->user // User
$event->from // string — previous status
$event->to // string — new status
$event->reason // string|null
$event->expiresAt // Carbon|null — for timed bans
// AccountDeleted
$event->user // User (still accessible during grace period)
$event->gracePeriodDays // int
$event->scheduledPurgeAt // Carbon
// AccountPurged
$event->userId // int — original user ID (row may be hard-deleted)
$event->email // string|null — from the deleted_accounts snapshot
// PhoneVerified (v2.6)
$event->user // User
$event->phone // string — normalized E.164 phone
// TwoFactorEnrolled / TwoFactorDisabled / TwoFactorVerified (v2.6)
$event->user // User
$event->method // string — 'totp' | 'email' | 'sms'
// TwoFactorChallengeIssued (v2.6)
$event->user // User
$event->challengeToken // string — UUID the client sends to /auth/2fa/challenge
$event->method // string — method the code was issued for
$event->reused // bool — true if an existing unconsumed challenge was reused
// TwoFactorChallengeFailed (v2.6)
$event->user // User
$event->method // string — method attempted (or 'backup')
$event->attempts // int — failed-attempt count after this failure
// TrustedDeviceAdded (v2.6)
$event->user // User
$event->deviceId // int — auth_trusted_devices row id
$event->level // string — 'low' | 'medium' | 'high'
// TrustedDeviceRevoked (v2.6)
$event->user // User
$event->deviceId // int — 0 when revoke-all
$event->reason // string — e.g. 'user', 'user_all'
Laravel 11+ auto-discovers listeners in app/Listeners/ by reading the type-hint of each class's handle() method. No service provider registration needed.
Zero-config listener:
<?php
namespace App\Listeners;
use Joe404\LaravelAuth\Events\EmailVerified;
class GrantSignupBonus
{
public function handle(EmailVerified $event): void
{
$user = $event->user;
// Run any host-app logic that should happen at registration
$user->wallet()->create(['balance' => 0]);
}
}
Drop the file in app/Listeners/ and the next time EmailVerified fires, this runs.
Verify wiring:
php artisan event:list --event="Joe404\LaravelAuth\Events\EmailVerified"
All registered listeners for that event are printed. Use this to confirm a new listener is picked up, or to spot an accidental double-registration.
Drop additional files in app/Listeners/. Order is not guaranteed; they run independently.
// app/Listeners/SeedFanWallet.php
class SeedFanWallet
{
public function handle(EmailVerified $event): void
{
$event->user->wallet()->create(['balance' => 0]);
}
}
// app/Listeners/RecordRegistrationAudit.php
class RecordRegistrationAudit
{
public function handle(EmailVerified $event): void
{
AuditLog::create(['event' => 'registered', 'user_id' => $event->user->id]);
}
}
// app/Listeners/SendBrandedWelcomeEmail.php
class SendBrandedWelcomeEmail implements ShouldQueue
{
public function handle(EmailVerified $event): void
{
Mail::to($event->user)->send(new WelcomeMailable($event->user));
}
}
All three run on every EmailVerified dispatch. Each team owns and maintains their listener independently.
Implement ShouldQueue and the listener runs in the background instead of inside the HTTP request. Recommended for anything that takes more than a few hundred milliseconds (email sending, analytics calls, webhook delivery).
<?php
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Joe404\LaravelAuth\Events\EmailVerified;
class SendBrandedWelcomeEmail implements ShouldQueue
{
public string $queue = 'mail';
public int $tries = 3;
public int $backoff = 30; // seconds between retry attempts
public function handle(EmailVerified $event): void
{
Mail::to($event->user)->send(new WelcomeMailable($event->user));
}
public function failed(EmailVerified $event, \Throwable $exception): void
{
// Called when all retries are exhausted — log, alert, etc.
Log::error('Welcome email failed', ['user_id' => $event->user->id]);
}
}
Scenario: a creator platform where three things must happen the moment a fan registers:
Old approach: one fat controller that does all three. Every change touches the same file.
Events approach: each team owns a tiny listener.
app/Listeners/SeedFanWallet.php → creates wallet (synchronous, fast)
app/Listeners/RecordRegistrationAudit.php → writes audit row (synchronous, fast)
app/Listeners/SendWelcomeEmail.php → queues the email (ShouldQueue)
// app/Listeners/SeedFanWallet.php
class SeedFanWallet
{
public function handle(EmailVerified $event): void
{
$event->user->wallet()->create(['balance' => 0]);
}
}
// app/Listeners/RecordRegistrationAudit.php
class RecordRegistrationAudit
{
public function handle(EmailVerified $event): void
{
RegistrationAudit::create([
'user_id' => $event->user->id,
'ip_address' => request()->ip(),
'registered_at' => now(),
]);
}
}
// app/Listeners/SendWelcomeEmail.php
class SendWelcomeEmail implements ShouldQueue
{
public string $queue = 'mail';
public function handle(EmailVerified $event): void
{
Mail::to($event->user)->queue(new WelcomeMailable($event->user));
}
}
The package, the controllers, and the other listeners are completely untouched regardless of which listener changes.
Because auto-discovery already binds your listener via the type-hint, an additional manual Event::listen() call in a service provider registers it a second time. The handle() method then runs twice for every dispatch — duplicate audit rows, duplicate emails, double wallet seeds.
Symptom: everything looks correct but effects happen twice.
Fix: remove the manual Event::listen() call and rely on auto-discovery alone.
// WRONG — do not add this if using auto-discovery
Event::listen(EmailVerified::class, SeedFanWallet::class);
// RIGHT — just drop the file in app/Listeners/ and let auto-discovery do it
Verify with:
php artisan event:list --event="Joe404\LaravelAuth\Events\EmailVerified"
If your listener appears twice in the output, you have a double registration.
If your team prefers explicit registration over auto-discovery, opt out in bootstrap/app.php:
->withEvents(discover: [])
Then register listeners manually in any service provider:
use Illuminate\Support\Facades\Event;
use Joe404\LaravelAuth\Events\EmailVerified;
use App\Listeners\SeedFanWallet;
Event::listen(EmailVerified::class, SeedFanWallet::class);
The package fires events the same way regardless — only the registration mechanism changes.
This section is for the frontend developer integrating the registration flow into a SPA. It is the part of the lifecycle most likely to be implemented wrong, because it crosses tabs / devices / browser contexts. The notes below capture the mistakes that were actually made building the reference integration; they apply to any framework (React, Vue, Svelte) — only the syntax changes.
The registration flow has three steps:
POST /auth/register — collect non-password fields, return temp_tokenPOST /auth/register/verify-otp or click a magic linkPOST /auth/register/complete — submit completion_token + passwordThe password is collected at step 3, never sent to the backend at step 1. This is deliberate: a stranger should not be able to set a password on an email address they do not own. (See the security rationale in AuthService::initiateRegistration().)
The mistake teams make: storing the password in sessionStorage at step 1 so step 3 can read it. That works only when verification happens in the same tab. The moment the user clicks the magic link in Gmail, on their phone, or in an incognito window, sessionStorage is empty and the flow dead-ends with "session expired".
Have the frontend render two views:
When verification succeeds in Tab B, the backend fires RegistrationEmailVerified. The event broadcasts on private-auth.verification.{tempToken} and includes the completion_token in the payload. Tab A is subscribed to that channel (it knows tempToken from step 1), receives the broadcast, stores the completion_token, and moves itself to the "set your password" view.
Tab A then collects password + confirmation and calls POST /auth/register/complete. Done.
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
const echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
// ... your Reverb host/port/scheme settings
client: new Pusher(import.meta.env.VITE_REVERB_APP_KEY, { /* ... */ }),
})
echo
.private(`auth.verification.${tempToken}`)
.listen('.RegistrationEmailVerified', (payload: {
verified: boolean
completion_token: string
email: string
}) => {
// Store payload.completion_token, navigate to your "set password" screen.
})
Notes:
'.RegistrationEmailVerified'. Without the dot, Echo prepends the Laravel namespace and your listener never fires.Broadcast::channel('auth.verification.{tempToken}', ...) (installed by php artisan auth:install) authorises subscribers without a logged-in user, by checking that the tempToken exists in auth_otp_codes. Do not delete that closure./broadcasting/auth must be reachable by the unauthenticated registering tab — make sure your Sanctum stateful domains and any auth middleware on the broadcast route do not block it.These are real bugs found and fixed during development. Avoid repeating them.
1. React StrictMode double-fires useEffect (and your magic link consumes itself).
In development, React 18+ StrictMode mounts → unmounts → remounts every component. An effect that calls GET /auth/register/verify-magic/{token} on mount runs twice in dev — the first call validates and sets used_at; the second hits the now-used record and returns 422. The first response succeeds in the background, but the UI shows the second response's error.
Fix: guard the effect with a useRef (refs persist across the StrictMode unmount/remount cycle for function components):
const verifyAttempted = useRef(false)
useEffect(() => {
const token = new URLSearchParams(window.location.search).get('token')
if (!token) return
if (verifyAttempted.current) return
verifyAttempted.current = true
// ... call verify-magic
}, [])
The symptom is "magic link always returns 422" — only in development, never in production (where StrictMode is a no-op). Easy to misdiagnose as a backend bug.
2. Don't subscribe to the channel keyed on completion_token from Tab A.
completion_token is the output of verification — Tab A does not have it yet. Subscribe with tempToken, which Tab A has had since step 1. The package broadcasts on auth.verification.{tempToken} precisely so Tab A can listen before verification happens.
3. Don't store the password in localStorage.
It "fixes" the cross-tab problem but at the cost of putting a plaintext password on disk for an unbounded time. The cross-tab handoff via broadcast removes the need entirely. (sessionStorage is also wrong, but at least it dies with the tab.)
4. Don't navigate Tab B forward after verification.
Once Tab B has consumed the completion_token (by hitting verify-magic), advancing it to a "set password" screen creates two tabs racing to call /auth/register/complete — and the cache entry is consumed by whichever lands first, leaving the other tab orphaned. Make Tab B a terminal screen ("verified, switch tabs"). Tab A is the one that finishes registration.
5. Resend invalidates previous OTPs and magic links — by design.
POST /auth/email/resend-verification calls OtpService::invalidatePrevious() before sending a new code. After resend, only the latest email's OTP / link works; clicking an old link returns 422. If your UI shows "resend email" without making this obvious, users will keep clicking the original email and report it as broken. Either make the latest-link rule explicit in the resend success message, or hide the resend button after the first send.
5a. Resend ROTATES the temp_token — re-bind your Echo subscription.
A resend creates a fresh set of OTP records under a new temp_token. The package broadcasts RegistrationEmailVerified on auth.verification.{NEW_TEMP_TOKEN}, so a tab still subscribed to the original channel will silently miss the event and look frozen.
The resend endpoint returns the new token in its response:
{ "success": true, "message": "...", "data": { "temp_token": "<new-uuid>" } }
Always read it and re-bind your store + channel:
const res = await authApi.resendVerification(email)
if (res.success && res.data?.temp_token) {
// Updating the temp_token in the store causes useRegistrationEcho's
// useEffect (deps: [tempToken]) to tear down the old subscription and
// open a new one on the now-valid channel.
store.setInitiation(res.data.temp_token, email)
}
The response is structurally identical whether or not a pending registration exists for that email (the package returns a dummy temp_token in the not-found case) — this preserves the anti-enumeration guarantee while letting honest SPAs always treat the field as authoritative.
6. Echo re-subscribes on every render if your callback identity changes.
If you pass an inline arrow function as your onVerified handler and include it in the effect's deps, every re-render produces a new closure → effect runs cleanup + re-subscribe → /broadcasting/auth POST fires on a loop. Any state update in the parent (e.g. a 1-second countdown timer) triggers this. Capture the callback in a ref and leave it out of the deps:
const onVerifiedRef = useRef(onVerified)
useEffect(() => { onVerifiedRef.current = onVerified })
useEffect(() => {
const channel = echo.private(`auth.verification.${tempToken}`)
channel.listen('.RegistrationEmailVerified', (payload) => onVerifiedRef.current(payload))
return () => { channel.stopListening('.RegistrationEmailVerified'); echo.leave(channel.name) }
}, [tempToken]) // NOT [tempToken, onVerified]
The symptom is a burst of OPTIONS/POST /broadcasting/auth requests in the network tab. Easy to mistake for a CORS or auth bug — actually a hook deps issue.
7. Orphaned sessions after out-of-band user deletion.
If a user row is deleted directly in the database (admin tool, manual SQL, test fixture cleanup) without going through DELETE /auth/account, the session row in storage survives but now points at a missing user ID. The next page load fires /auth/me and gets 401, the SPA "logs out" locally, but the browser still holds the stale session cookie. When the same browser then attempts a fresh registration, Auth::login(newUser) runs on top of the orphaned session and — depending on storage driver and timing — the Set-Cookie from the response can be silently rejected by the browser. /auth/me keeps returning 401 in a loop until the user clears cookies manually.
The package ships POST /auth/session/clear (unauthenticated, no body) specifically for this. It calls $request->session()->invalidate() + regenerateToken() and returns a fresh Set-Cookie so the next request starts clean. Wire it into your SPA's auth bootstrap on every 401 from `/a...
How can I help you explore Laravel packages today?