protonemedia/laravel-verify-new-email
A Laravel package for secure email changes: when users update their email, a verification link is sent to the new address and the account is only updated after confirmation, helping prevent typos, unauthorized changes, and account takeover.
Install and publish
composer require protonemedia/laravel-verify-new-email
php artisan vendor:publish --provider="ProtoneMedia\LaravelVerifyNewEmail\ServiceProvider"
php artisan migrate
Publishing gives you a config file (config/verify-new-email.php) and a migration that creates the pending_user_emails table. Run the migration before you touch the trait, otherwise the first newEmail() call will fail with a missing-table error.
If you're on PHP 8.4, pin ^1.13.0. v1.13.0 is a one-line deprecation fix (PR #27) and is the first release that runs clean on 8.4 — earlier versions work but pollute logs with deprecation notices.
Wire the trait into your authenticatable model
use ProtoneMedia\LaravelVerifyNewEmail\MustVerifyNewEmail;
class User extends Authenticatable
{
use MustVerifyNewEmail;
}
Where to look first, in this order
config/verify-new-email.php — user model, redirect target, expiration/throttle knobs.pending_user_emails tells you whether it's single-model or morph-based.MustVerifyNewEmail in vendor/protonemedia/laravel-verify-new-email/src — the public API you'll actually call (newEmail(), pending-email accessor, the verification handler).First use case (the 90% case)
A "change email" form on the profile page. The controller does not touch $user->email. Instead:
public function updateEmail(UpdateEmailRequest $request)
{
$request->user()->newEmail($request->validated('email'));
return back()->with('status', 'We sent a confirmation link to your new address.');
}
The user's current email keeps working (and stays valid for login) until they click the link sent to the new address. On verification the package swaps email, clears the pending record, and redirects to whatever redirect_after_verification is set to.
Validation is your job, not the package's
The old address stays in users.email, so unique:users,email alone will let two users race for the same pending address. Validate against both tables:
public function rules(): array
{
return [
'email' => [
'required', 'string', 'email', 'max:255',
Rule::unique('users', 'email'),
Rule::unique('pending_user_emails', 'email'),
],
];
}
Normalize before validating (strtolower(trim($value))) — the package stores what you hand it, and [email protected] vs [email protected] will otherwise produce two pending rows for the same mailbox.
Show the pending state in the UI
Whatever the trait exposes for the pending relation (usually $user->pendingEmail / hasPendingEmail()), render it so the user isn't confused about which address is live:
@if ($user->pendingEmail)
<p class="text-amber-600">
Confirming {{ $user->pendingEmail->email }} — your current address stays active until then.
</p>
@endif
Add a "resend" action that simply calls newEmail() again with the same address; gate it behind a RateLimiter (e.g. 3/hour per user) because the package does not stop a user from spamming their own inbox.
Integrate with your profile-update flow
$user->update($request->all()) path. A single email in a mass-assignment payload will silently bypass verification. Consider making email non-fillable and routing all changes through newEmail().newEmail() or write email directly. Admins usually want a direct write; document the divergence.parent:: — the latter is more stable across package versions.Customizing the email
The notification is a normal Laravel notification. Two clean options:
User model and return your own notification class.Then translate the copy, brand the mail, and — importantly — make sure the new address is in the to and the old address is not cc'd (a common copy/paste bug when forking the template).
SPA / Inertia / API front ends
The verification click is a browser GET, not an API call, so it will land on your redirect route and lose the XHR session state. Pattern that works well:
redirect_after_verification to a front-end route (e.g. /settings/email?verified=1).Testing
Notification::fake();
$user->newEmail('[email protected]');
Notification::assertSentOnDemand(
VerifyNewEmail::class,
fn ($n, $channels, $notifiable) => $notifiable->routes['mail'] === '[email protected]'
);
$this->assertDatabaseHas('pending_user_emails', ['email' => '[email protected]']);
Then grab the pending record, build the URL the package expects (it's a signed/token route — copy the pattern from the package's own tests), hit it, and assert:
$user->refresh();
$this->assertSame('[email protected]', $user->email);
$this->assertDatabaseCount('pending_user_emails', 0);
users.email — keep it that way).newEmail() five times with five addresses. Decide your policy (latest-wins is the sane one) and enforce it with pendingEmail()->delete() before the new call, or a unique index on the pending table.pending_user_emails rows; the package doesn't ship a cleanup command.user_model, redirect, or expiration requires php artisan config:cache to be re-run in production. Stale config here produces the maddening "it works locally" class of bug.MustVerifyEmail coexistence. If your model also uses Laravel's MustVerifyEmail, both traits touch verification concerns and sendEmailVerificationNotification() can get ambiguous. If you don't use Laravel's email verification, drop it; if you do, alias the methods and be explicit about which path fires.verified middleware. The user remains verified under the old address the whole time, so verified middleware keeps working. Don't add verified to the verification route itself.user_id or morphs('user'). If it's a plain FK, multiple authenticatable models need a migration change before you go live, not after.[email protected].config/logging.php, Sentry before_send) so a support bundle doesn't leak account-takeover links.ShouldQueue, the pending row is written before the send, so a dead queue leaves orphaned pending rows and a user who never gets mail. Alert on queue depth, and keep the "resend" button as the escape hatch.MustVerifyNewEmail before a PHP or Laravel major upgrade rather than relying on release notes — the version bump is a signal that someone ran CI on the new runtime, not that anything in the public surface changed. If you maintain a support matrix, treat PHP 8.4 as requiring ^1.13.0.How can I help you explore Laravel packages today?