After setting up a fresh installation of Laravel 11 with multiple authentication guards I ran into a problem. The problem was, out of the box Laravel doesn't play nicely with email verification.
All the other key parts work without a fault such as the "remember me" feature or password reset. Only the darned email verification.
The frustration for me was the verification link. For the web guard and the admin guard the link remained the same despite in the routes there was the separation for each guard.
That was the main fault but there was another little niggle.
For the admin guard after registering there should be a notice of the verification email having been sent. No such thing, all I got was the register screen once more.
Having clicked on the verification link I would be redirected to the login screen of the default guard (http://localhost:8080/login
) when in fact I should have been logged in authenticated as the admin (http://localhost:8080/admin/dashboard
).
Obviously, the database wasn't updated either in that regards to the email_verified_at
column.
The niggle was removed with the following (below) in the routes.
I separated a lot with the multi authentication. I separated the routes, and I kept the controllers for the authentication of both guards separate too.
Below is the routes and not a lot different from how many others manage their routes either. But do pay close attention to the middleware and the inclusion of the verification notice route.
That's important and the solution to ensuring the email verification notice is displayed.
Route::prefix('admin')->middleware('auth:admin')->group(function()
{
Route::prefix('dashboard')->middleware('verified:admin.verification.notice')->group(function()
{
Route::get('/', [DashboardController::class, 'index'])->name('admin.dashboard');
});
Route::get('verify-email', EmailVerificationPromptController::class)->name('admin.verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)->middleware(['signed', 'throttle:6,1'])->name('admin.verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])->middleware('throttle:6,1')->name('admin.verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])->name('admin.password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
Route::put('password', [PasswordController::class, 'update'])->name('admin.password.update');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])->name('admin.logout');
});
I copied the original controllers under the Auth directory (./app/Http/Controllers/Auth/
) to two separate directories:
./app/Http/Controllers/Admin/Auth/
./app/Http/Controllers/User/Auth/
I'm sure I will cover multi authentication in a later post but for now, the focus is on the email verification. For all your controller end points for a verified user obviously put them inside the closure along with the admin.dashboard
route.
The default guard follows below. What is important from my point of view is that you should be using auth()->guard('web')
and auth()->guard('admin')
when accessing the user model.
That is clear when you see the changes I made to the default verification controllers below.
Route::middleware('auth:web')->group(function()
{
Route::prefix('dashboard')->middleware('verified:verification.notice')->group(function()
{
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
});
Route::get('verify-email', EmailVerificationPromptController::class)->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)->middleware(['signed', 'throttle:6,1'])->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])->middleware('throttle:6,1')->name('verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])->name('password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
});
Aside from the admin.
prefix there are few other differences. The bigger differences follow below in the three controllers that glue it all together:
EmailVerificationNotificationController
EmailVerificationPromptController
VerifyEmailController
As stated above, you must (and should anyway throughout your application) specify the guard when accessing the model. Let's take a look at the changes I made.
namespace App\Http\Controllers\Admin\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request) : RedirectResponse
{
if(auth()->guard('admin')->user()->hasVerifiedEmail()) {
return redirect()->intended(route('admin.dashboard', absolute: false));
}
auth()->guard('admin')->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}
namespace App\Http\Controllers\Admin\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request) : RedirectResponse|Response
{
return auth()->guard('admin')->user()->hasVerifiedEmail() ?
redirect()->intended(route('admin.dashboard', absolute: false)) :
inertia('Admin/Auth/VerifyEmail', ['status' => session('status')]);
}
}
namespace App\Http\Controllers\Admin\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request) : RedirectResponse
{
if(auth()->guard('admin')->user()->hasVerifiedEmail()) {
return redirect()->intended(route('admin.dashboard', absolute: false).'?verified=1');
}
if(auth()->guard('admin')->user()->markEmailAsVerified()) {
event(new Verified(auth()->guard('admin')->user()));
}
return redirect()->intended(route('admin.dashboard', absolute: false).'?verified=1');
}
}
In all three controllers I have replaced the original $request->user()
with auth()->guard('admin')
in the appropriate places. The same with the three controllers found under the ./app/Http/Controllers/User/Auth/
directory.
Now, when you register as a User your verification email will have a link pointing to, for example:
http://localhost:8080/verify-email/152/999cf04ca5b25ad5d83a1cb61e22a98ee7f49acd?expires=1730919144&signature=89801e25c72f168e5725c194a6cf4a87139931448558e387d940db5d10a422d1
And when registering as an Admin your verification email will have its own link too:
http://localhost:8080/admin/verify-email/51/999cf04ca5b25ad5d83a1cb61e22a98ee7f49acd?expires=1730919066&signature=b862842fbccb9336f58d6d016d2f1f6b765dbff6876bbbd057b0ca9e71f9889e
On reflection, and after a refactoring of the above implementation, the update below is actually a better option I've found. To begin, the best place is to look at Laravel 11's application service provider. I have the following namespace imports:
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use App\Listeners\ActivateNewUserRegistration;
use App\Listeners\ActivateNewAdminRegistration;
And further into the boot()
class method, I register the following details.
#################################################################################
# Event Listeners #
#################################################################################
/**
* @note listeners for events for laravel authentication layer
*
* - Verified
*
* + update either user, or admin database with activation
* status and verification date
*
* - Registered
*
* + send the standard, initial (first) verification email
*/
Event::listen(
Verified::class, [
ActivateNewUserRegistration::class,
ActivateNewAdminRegistration::class,
]
);
Event::listen(Registered::class, SendEmailVerificationNotification::class);
The listener is for the User (web guard) first followed with the Admin (admin guard) below.
class ActivateNewUserRegistration
{
public function __construct() {}
/**
* Handle the event when a user's email has been verified, activating
* an account by updating the active column
*
* @param Veriafied $event An instance of the event holding data about the user
* @return void
*/
public function handle(Verified $event) : void
{
/**
* @note find the user, via the $event, and activate account, since now
* verified by clicking on the verification link in email
*/
$user = User::find($event->user->id);
$user->update([
'active' => true,
'email_verified_at' => now(),
]);
}
}
What follow below is what ensures the admin gets their email verification notice, to click on the verification link allowing them access to their newly created account. Without this email, the administrator isn't going anywhere.
class ActivateNewAdminRegistration
{
public function __construct() {}
/**
* Handle the event when an admin's email has been verified, activating
* an account by updating the active column
*
* @param Veriafied $event An instance of the event holding data about the admin
* @return void
*/
public function handle(Verified $event) : void
{
/**
* @note find the admin, via the $event, and activate account, since now
* verified by clicking on the verification link in email
*
* the email_verified_at column is updated once admin clicks on the
* verification link, and not before
*/
$admin = Admin::find($event->admin->id);
$admin->update([
'active' => true,
'email_verified_at' => now(),
]);
}
}
With the listeners out of the way, need to create two new events, one each for User and one for Admin. These events are generic in nature with minimal implementation, below.
class UserVerified
{
use SerializesModels;
/**
* The verified user.
*
* @var \Illuminate\Contracts\Auth\MustVerifyEmail
*/
public $user;
/**
* Create a new event instance.
*
* @param \Illuminate\Contracts\Auth\MustVerifyEmail $user
* @return void
*/
public function __construct($user)
{
$this->user = $user;
}
}
And for the administrator, their event.
class AdminVerified
{
use SerializesModels;
/**
* The verified admin.
*
* @var \Illuminate\Contracts\Auth\MustVerifyEmail
*/
public $admin;
/**
* Create a new event instance.
*
* @param \Illuminate\Contracts\Auth\MustVerifyEmail $admin
* @return void
*/
public function __construct($admin)
{
$this->admin = $admin;
}
}
Now need to refactor each VerifyEmailController.php
accordingly to use the new event and not the one already plumbed in, the Verified
one. Continue to read for the change, it's very easy to locate the one line and change to suit your own needs.
// ... app\Http\Controllers\User\Auth\VerifyEmailController.php
// ... etc ...
if($request->user()->markEmailAsVerified())
{
/**
* @note triggered once the user has successfully verified their
* email, afterwards
*
* update the database for the user in question, activate their
* account and date of email verification
*
* @see ./Listeners/ActivateNewUserRegistration.php
*
*/
event(new UserVerified($request->user()));
}
// etc,...
There are two such controllers one each for User and for Admin.
// ...
event(new AdminVerified($request->user()));
// etc,...
Because the standard Laravel 11 framework already sends the verification email to the User (the web guard) there isn't much that can be done about that. But to guarantee the Admin gets them own verification email, and the endpoint route goes to their part (and not that of the User) you must create a notification.
class AdminVerifyEmail extends VerifyEmail
{
/**
* Get the verification URL for the given notifiable
*
* @param mixed $notifiable
* @return string
*/
protected function verificationUrl($notifiable) : string
{
return URL::temporarySignedRoute(
'admin.verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
}
Use the following artisan command to create this notification for you: php artisan make:notification AdminVerifyEmail
and you're good to go. Finally, add the following to the Admin model without further ado.
public function sendEmailVerificationNotification()
{
$this->notify(new AdminVerifyEmail());
}
Hopefully this helps you get you back on track. In my opinion, in this day and age multi authentication should be working "out of the box" as part of a breeze installation and not having to do it ourselves.
Content on this site is licensed under a Creative Commons Attribution 4.0 International License. You are encouraged to link to, and share but with attribution.
Copyright ©2025 Leslie Quinn.