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.
One final thing to note. You must override one class method on the framework's VerifyEmail
implementation. Inside the boot method of the application service provider, add:
VerifyEmail::createUrlUsing(function($notifiable)
{
$route = 'verification.verify';
if(auth()->guard('admin')->check())
{
$route = 'admin.verification.verify';
}
return URL::temporarySignedRoute(
$route,
Carbon::now()->addMinutes(config('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
});
Basically, your own implementation is toggling between the various routes given to each authentication model.
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
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 ©2024 Leslie Quinn.