Changing email after verification in Laravel
By default, Laravel Jetstream allows users to change their email before the email verification process is completed.
This implementation means that it is possible for users to accidently lock themselves out of their account, which could translate into a customer service nightmare that could easily be prevented.
It would also be much more intuitive to only update the email after the user has verified it via email.
Let's explore how to change this behavior so that the email is only updated after it has been verified.
Create new fields for User model
Start by creating the structures necessary to house the data for this new behavior we are implementing.
We need to create a field that will store the email the user will be updating to, as well as a field to store a verification token to match the user account to in our callback route.
Create a new migration to add the columns to the users table:
$php artisan make:migration add_email_upcoming_and_verification_token_columns_to_users_table
The migration could look like this:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('email_verification_token')->nullable();
$table->string('email_upcoming')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email_verification_token');
$table->dropColumn('email_upcoming');
});
}
};
Run the migration:
$php artisan migrate
Enable Fortify email verification
First, enable (uncomment) the email verification feature in Fortify's configuration file:
'features' => [
Features::registration(),
Features::resetPasswords(),
//Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
],
Make sure the User model implements the MustVerifyEmail contract:
class User extends Authenticatable implements MustVerifyEmail
Revise Fortify update profile flow
Fortify exposes an action that we can revise to implement custom functionality to the profile update logic.
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email_upcoming' => $input['email'],
'email_verification_token' => Str::random(32)
])->save();
$url = URL::temporarySignedRoute(
'verify-email',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'token' => $user->email_verification_token
]
);
Notification::route('mail', $user->email_upcoming)->notify(new VerifyEmail($url));
}
Instead of updating the email address here (which is the default behavior), we will revise the code to create a verification token that will be used to verify the user as well as set the email to be updated.
Then we can create a temporary signed route that will expire in 60 minutes and send a notification to the new email address with a verification link. This verification link will call a route that will update the user account with the new email address.
I also ended up borrowing the original VerifyEmail notification, and revising it to include a parameter when it is constructed to house the route.
This can be done by creating a new notification:
$php artisan make:notification VerifyEmail
For brevity's sake, I will also include the contents of the file:
<?php
namespace App\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\URL;
class VerifyEmail extends Notification
{
/**
* The callback that should be used to create the verify email URL.
*
* @var \Closure|null
*/
public static $createUrlCallback;
/**
* The callback that should be used to build the mail message.
*
* @var \Closure|null
*/
public static $toMailCallback;
private static $verificationUrl;
public function __construct($verificationUrl)
{
static::$verificationUrl = $verificationUrl;
}
/**
* Get the notification's channels.
*
* @param mixed $notifiable
* @return array|string
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Build the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$verificationUrl = static::$verificationUrl;
if (static::$toMailCallback) {
return call_user_func(static::$toMailCallback, $notifiable, $verificationUrl);
}
return $this->buildMailMessage($verificationUrl);
}
/**
* Get the verify email notification mail message for the given URL.
*
* @param string $url
* @return \Illuminate\Notifications\Messages\MailMessage
*/
protected function buildMailMessage($url)
{
return (new MailMessage)
->subject(Lang::get('Verify Email Address'))
->line(Lang::get('Click the button below to verify your email address.'))
->action(Lang::get('Verify Email Address'), $url);
}
/**
* Get the verification URL for the given notifiable.
*
* @param mixed $notifiable
* @return string
*/
protected function verificationUrl($notifiable)
{
if (static::$createUrlCallback) {
return call_user_func(static::$createUrlCallback, $notifiable);
}
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
/**
* Set a callback that should be used when creating the email verification URL.
*
* @param \Closure $callback
* @return void
*/
public static function createUrlUsing($callback)
{
static::$createUrlCallback = $callback;
}
/**
* Set a callback that should be used when building the notification mail message.
*
* @param \Closure $callback
* @return void
*/
public static function toMailUsing($callback)
{
static::$toMailCallback = $callback;
}
}
Implement route callback to update email address
We need to define a route that will handle the logic for updating the email address for the user after it has been verified by email.
Route::get('/verify/{token}', function (Request $request, $token) {
if (!$request->hasValidSignature()) {
abort(401);
}
$user = User::where('email_verification_token', $token)->firstOrFail();
$user->email = $user->email_upcoming;
$user->email_upcoming = null;
$user->email_verification_token = null;
$user->save();
Session::flash('flash.banner', 'Your updated e-mail address has been set!');
Session::flash('flash.bannerStyle', 'success');
return redirect()->route('profile.show');
})->name('verify-email');
First, we are checking that the request is valid (within the timeframe allowed for the temporary signed route).
Second, we find the account that will be updated (by the verification token) and update its email address.
Finally, we flash a banner letting the user know the email has been updated.
Thanks for reading!
If you have any questions, comments, or concerns - feel free to leave a comment.
I'm also always looking for ways to improve my articles and code.