beyondcode/laravel-confirm-email
Laravel package that adds email confirmation to the default authentication flow. After registration, users receive a signed confirmation link; middleware and routes protect unconfirmed accounts and support resending. Configurable views, redirects, and expiration make setup quick.
Confirm you're the right kind of app first. This package retrofits confirmation onto controller-based session auth — laravel/ui, make:auth, or a hand-rolled login/register controller. Before writing any code, run two checks:
composer show laravel/ui # present => likely a good fit
grep -rn "AuthenticatesUsers\|RegistersUsers" app/Http/Controllers/Auth/
If the app is on Breeze, Jetstream, or Fortify, stop. Those ship MustVerifyEmail plus verification routes and middleware; installing this is duplicate behavior with route/middleware collisions. The decision there is remove/never add, not integrate.
The minimal install path (roughly an afternoon, including tests):
composer require beyondcode/laravel-confirm-email
php artisan vendor:publish --provider="BeyondCode\EmailConfirmation\EmailConfirmationServiceProvider"
php artisan migrate
That publish step gives you the migration (the confirmed_at column on users), the confirmation notice view, and package routes (confirmation.notice, confirmation.resend, confirmation.confirm). Then wire up four things:
use BeyondCode\EmailConfirmation\Traits\Confirmable;
class User extends Authenticatable
{
use Confirmable; // alongside Notifiable
}
Trait swaps in your auth controllers. LoginController and RegisterController swap Laravel's AuthenticatesUsers / RegistersUsers for the package's equivalents (BeyondCode\EmailConfirmation\Traits\...). Keep everything else in those controllers — throttling, redirectTo(), audit logging — intact.
Middleware on protected route groups, typically aliased in Kernel.php / bootstrap/app.php and applied to authenticated areas only:
Route::middleware(['auth', 'confirmation'])->group(function () {
Route::get('/home', ...);
});
APP_URL and mailer. Use MAIL_MAILER=log locally and read the signed link out of storage/logs/laravel.log.Where to look first. Read the published migration (what column actually gates verification), then the Confirmable trait (what confirmed() / the confirm handler actually do), then the package routes. Those three files are the whole mental model; everything else is wiring.
First use case to get working end-to-end: register a user → observe the confirmation email in the log → visit the link in an incognito window → land on your post-confirmation page → confirm that hitting a protected route before clicking bounced to the notice page and after clicking succeeds. That single loop exercises every moving part.
Gate the group, not the whole app. The confirmation middleware belongs on authenticated route groups. Routes that must stay reachable while unconfirmed — the notice page, resend, confirm, logout, login — must be outside that group. A common shape:
// reachable while unconfirmed
Route::middleware('auth')->group(function () {
Route::get('/confirmation/notice', ...)->name('confirmation.notice');
Route::post('/confirmation/resend', ...)->name('confirmation.resend');
});
// gated
Route::middleware(['auth', 'confirmation'])->group(function () {
// everything else
});
Override the redirect, don't fork the trait. Post-confirmation and post-registration redirects are the two places teams always want to change. Prefer the published config key / redirectTo() override in the controller over editing vendor code — and note that the target must itself be outside the confirmation gate or you get a loop.
Backfill before you tighten. Turning this on in an app with existing users locks everyone out on deploy day. Sequence it in two steps: ship the migration and middleware, then run a data migration granting confirmed_at = now() (or your registration date) to all pre-existing rows. Only accounts created after go-live get the real gate. Do this as a separate PR so the "who is grandfathered" decision is reviewable, not buried in a schema change.
Handle email changes explicitly. The trait covers the registration case; profile email updates are yours. When a user changes their address, reset the confirmation state and re-send — otherwise a verified account becomes a verified account with an unverified address, which is exactly the hole you were closing.
$user->update(['email' => $request->email, 'confirmed_at' => null]);
$user->sendConfirmationEmail();
Resend needs its own throttle. The package gives you a resend route; it does not give you abuse protection. Put throttle: middleware on it — unthrottled resend is a free mail-bomb against your provider quota, and a launch-day spike against a provider rate limit blocks onboarding outright.
Customize by publishing, not patching. Three extension points, in increasing order of invasiveness: publish and edit the notice view; extend/replace the confirmation notification to change subject, branding, or to implement ShouldQueue; extend the Confirmable trait in your own app/Traits/Confirmable.php if you need extra behavior. Never vendor/-edit — this is single-maintainer, maintenance-mode OSS, and you will need to re-pull it on every Laravel bump.
Testing patterns that actually catch regressions. Fake notifications in registration tests and assert the outcome, not the mail body:
Notification::fake();
$this->post('/register', [...]);
Notification::assertSentTo(User::first(), ConfirmEmail::class);
$this->actingAs($user)->get('/home')->assertRedirect(route('confirmation.notice'));
For the confirm path, generate the URL the way the notification does (a temporary signed route for the user) and hit it directly — this is far more robust than parsing the mailable. For expiry, $this->travel(61)->minutes() before hitting the link and assert the expired-link response. And make your factory's default confirmed_at => now() so the 90% of tests that aren't about verification don't have to think about it — with an unconfirmed() state for the ones that are.
Keep a small set of must-cover paths in CI: register → confirm → access; resend; already-confirmed user hitting the confirm link; expired link; unconfirmed user hitting a gated route. Those five are the entire risk surface, and they're what make a composer update bump provably safe.
APP_URL is the number one source of "invalid signature." Confirmation links are signed, so the URL used to generate the link must match the URL the browser hits. Behind a load balancer or TLS terminator, you need TrustProxies configured and often URL::forceScheme('https') in a service provider. Symptom: every confirmation click in production 403s while local and staging work fine. Test the real click path in staging over https before rollout.
Unconfirmed users can still log in. That's the design — the gate is the middleware, not the login flow. The dangerous failure mode is applying the middleware too narrowly: a route outside the gated group is silently unprotected, and it looks like success because nothing errors. Audit your route list after wiring (php artisan route:list) and check that everything authenticated sits inside the group except the deliberately-excluded few.
Infinite redirect loops come from putting the notice route inside the gate — or from pointing the post-confirmation redirect at a gated URL. If you see a redirect loop, check those two first.
Expired links are a UX problem, not a bug. Signed URLs expire; users open email on Monday what they registered for on Friday. Make the failure page explicit ("this link has expired — request a new one") with a working resend button, rather than letting a raw 403 land. Same for tampered/shortened links.
Mail delivery is the whole feature. If the notification implements ShouldQueue, a stopped worker means zero confirmations and zero errors — silent failure. Alert on mail-send failures and on confirmation-flow 403 rate; watch provider quota before a launch.
Email case and trimming. If you normalize email anywhere (lowercase on register), make sure the confirm path resolves the same way, or users who registered with odd casing get a link that doesn't match their current record. Signed URLs bind to the user id and email hash, so a whitespace difference breaks the signature.
Route caching. The package registers routes in its provider. If php artisan route:cache was built before install or after a config change, you'll get 404s on confirmation.*. Clear config and route caches after installing, after changing redirect config, and in deploy scripts. php artisan config:clear is the reflex for "my config change did nothing."
Debugging a 403 on confirm: decode the query string and check expires (clock skew between servers is real), then compare the URL's scheme/host against APP_URL, then confirm the user still exists (deleted or soft-deleted users produce confusing errors on a valid link).
Pin and sequence. Use ^2.5 for Laravel 13 — 2.4.0 will not resolve. Upgrade the package before the framework major, never after, and re-run the five-path regression suite as the proof. Release 2.5.0 is a compatibility-only change: no API, config, or route changes, so a clean test run is the entire acceptance criterion.
Write down the exit. This is a narrow, single-maintainer dependency in reactive maintenance. Document the config keys, route names, and middleware alias in your own repo, assign an internal owner, and record the native MustVerifyEmail fallback path. The cheapest exit is usually "migrate to a modern starter kit's built-in verification on the next auth-touching workstream, then delete this" — and someone should be able to execute that without reverse-engineering why the package is there.
How can I help you explore Laravel packages today?