Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Verify New Email Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. config/verify-new-email.php — user model, redirect target, expiration/throttle knobs.
  2. The published migration — the shape of pending_user_emails tells you whether it's single-model or morph-based.
  3. MustVerifyNewEmail in vendor/protonemedia/laravel-verify-new-email/src — the public API you'll actually call (newEmail(), pending-email accessor, the verification handler).
  4. The notification class the package sends — that's your main customization point.
  5. The package's routes file — the signed verification route is registered for you; you generally do not write a route or controller for the click-through.

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.

Implementation Patterns

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

  • Keep the email field out of the generic $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().
  • If you have other flows that change identity (admin editing a user, SSO provisioning, imports), decide explicitly whether they go through newEmail() or write email directly. Admins usually want a direct write; document the divergence.
  • Fire your own domain event after the swap by listening for the package's events or by overriding the trait's verification method in your model and calling parent:: — the latter is more stable across package versions.

Customizing the email

The notification is a normal Laravel notification. Two clean options:

  1. Override the trait method that dispatches it in your User model and return your own notification class.
  2. Bind/extend the notification in a service provider if your version resolves it from the container.

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:

  • Set redirect_after_verification to a front-end route (e.g. /settings/email?verified=1).
  • Read the query flag in the page component and show the toast there.
  • For pure-API clients, point the redirect at a small controller that returns JSON or a deep link, and keep the web route registered.

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);

Gotchas and Tips

  • Login with the new address fails until verified. That's the whole point, but it surprises users and support staff. Say so in the confirmation UI, and make sure "forgot password" doesn't send a reset to an unverified address (it won't, if you only look at users.email — keep it that way).
  • Duplicate pending rows. Nothing in the package prevents a user from calling 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.
  • Expired tokens. There is an expiration window in config. Expired links should produce a friendly "request a new link" page, not a 403 stack trace — catch it in the redirect/controller layer. Also schedule a prune of stale pending_user_emails rows; the package doesn't ship a cleanup command.
  • Config cache. Changing 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.
  • The 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.
  • Multi-model / multi-guard apps. Check whether the published migration uses user_id or morphs('user'). If it's a plain FK, multiple authenticatable models need a migration change before you go live, not after.
  • Case and whitespace. Lowercase and trim at the boundary (form request or model mutator). Emails are practically case-insensitive at the domain level and users will type [email protected].
  • Don't log the token. Verification URLs contain the secret; scrub them from request logging and error reporting (config/logging.php, Sentry before_send) so a support bundle doesn't leak account-takeover links.
  • Queue workers. If your notification implements 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.
  • Extension points, in order of preference: custom notification class → overriding trait methods in your model → listening to package events → forking the controller. Reaching for a fork should be a last resort; the package's surface is small enough that a couple of overrides usually covers branding, redirects, and extra side effects like notifying the old address ("your email was changed") — which is a genuinely good security practice and one the package does not do for you.
  • Version and upgrade check. This is a small, low-churn package; the API has been stable for years and releases are mostly compatibility patches (v1.13.0 is a single PHP 8.4 deprecation fix). The practical consequence: you can pin a caret constraint and forget about it, but do re-read 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox