Technical · Dispatch № 17

How to Extend MustVerifyEmail

Creating MustVerifyPhoneNumber in Laravel.

Every time I start a new project in Laravel with a starter kit, I enable the MustVerifyEmail feature. And I always take for granted that it works. The Laravel team does a great job making sure it works. But, recently, I asked myself, how does it actually work? That's what I'm going to explain in this article.

I'm going to explain to you by doing my own MustVerifyPhoneNumber feature. Let's get started.

The first thing is to create an interface called MustVerifyPhone. It is pretty similar to MustVerifyEmail. Below, you'll find the content of it:

interface MustVerifyPhoneNumber
{
    /**
     * Determine if the user has verified their phone number.
     */
    public function hasVerifiedPhoneNumber(): bool;
 
    /**
     * Mark the given user's phone number as verified.
     */
    public function markPhoneNumberAsVerified(): bool;
 
    /**
     * Send the phone number verification notification.
     */
    public function sendPhoneNumberVerificationNotification(): void;
 
    /**
     * Get the phone number that should be used for verification.
     */
    public function getPhoneNumberForVerification(): string;
}

Now, we need to implement this interface in your model, in this case, the User model:

class User extends Authenticatable implements MustVerifyEmail, MustVerifyPhoneNumber
{
    ...
}

MustVerifyEmail actually implements the required methods in a trait. But wait, no trait implements the interface in the User model, right? Well, the trait is on the Authenticatable base class. If you go to the base class, you'll see a MustVerifyEmail trait. Let's do something similar.

I created a MustVerifyPhoneNumber trait with the following content:

trait MustVerifyPhoneNumber
{
    public function hasVerifiedPhoneNumber(): bool
    {
        return ! is_null($this->phone_verified_at);
    }
 
    public function markPhoneNumberAsVerified(): bool
    {
        return $this->forceFill([
            'phone_verified_at' => $this->freshTimestamp(),
        ])->save();
    }
 
    public function sendPhoneNumberVerificationNotification(): void
    {
        $this->notify(new VerifyPhoneNumberNotification);
    }
 
    public function getPhoneNumberForVerification(): string
    {
        return $this->phone_number;
    }
}

As you can see, there are two new columns we need to add to the users table: phone_number and phone_verified_at. We also need to create VerifyPhoneNumberNotification. Here is an example of the notification. Of course, you can create your own version:

class VerifyPhoneNumberNotification extends Notification implements ShouldQueue
{
    use Queueable;
 
    public function __construct() {}
 
    /**
     * @return string[]
     */
    public function via(User $notifiable): array
    {
        return [WhatsappChannel::class];
    }
 
    public function toWhatsapp(User $notifiable): TemplateMessage
    {
        $url = $this->verificationUrl($notifiable);
 
        return (new TemplateMessage)
            ->name('verify_phone_number_v1')
            ->addUrlButton($url);
    }
 
    protected function verificationUrl(User $notifiable): string
    {
        return URL::temporarySignedRoute(
            'verification.verify_phone',
            Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
            [
                'id' => $notifiable->getKey(),
                'hash' => sha1($notifiable->getPhoneNumberForVerification()),
            ]
        );
    }
}

You can create your own custom channel to send the notification if you want. However, the part I want you to examine is the verificationUrl method. See how we generate a temporary signed route that we are going to send to the user. I took this from the Illuminate\Auth\Notifications\VerifyEmail notification. You can send the user to a controller to verify the phone number and finally redirect to your dashboard.

The next step is to override the verified middleware. This middleware ensures that the user is redirected to the verification page if the user is not verified. Let's create our own version of the middleware.

class EnsureUserIsValidatedMiddleware
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response|RedirectResponse
    {
        if (
            $this->emailNotVerified(request()->user())
            || $this->phoneNumberNotVerified(request()->user())
        ) {
            return Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));
        }
 
        return $next($request);
    }
 
    private function emailNotVerified(?User $user): bool
    {
        return ! $user?->hasVerifiedEmail();
    }
 
    private function phoneNumberNotVerified(?User $user): bool
    {
        return ! $user?->hasVerifiedPhoneNumber();
    }
}

In my case, I verify that both the email and phone number are verified; if not, we redirect to the verification.notice route. You can find this route in the auth.php routes file. Also, you can check the default verified middleware in Illuminate\Auth\Middleware\EnsureEmailIsVerified.

Finally, we just need to override the verified middleware in bootstrap/app.php like this:

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'verified' => EnsureUserIsValidatedMiddleware::class,
        ]);
 
        ...
    })->create();

And this is how you can implement your own verification feature based on the MustVerifyEmail feature from Laravel.