Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Redirect when "Email Not Verified"

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

It's cool that we can listen to the CheckPassportEvent and cause authentication to fail by throwing any authentication exception, like this CustomUserMessageAuthenticationException:

... lines 1 - 8
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
... lines 10 - 12
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
... lines 16 - 26
if (!$user->getIsVerified()) {
throw new CustomUserMessageAuthenticationException(
'Please verify your account before logging in.'
);
}
}
... lines 33 - 39
}

But what if, instead of the normal failure behavior - where we redirect to the login page and show the error - we want to do something different. What if, in just this situation, we want to redirect to a totally different page so we can explain that their email isn't verified... and maybe even allow them to resend that email.

Well, unfortunately, there is no way - on this event - to control the failure response. There's no $event->setResponse() or anything like that.

So we can't control the error behavior from here, but we can control it by listening to a different event. We'll "signal" from this event that the account wasn't verified, look for that signal from a different event listener, and redirect to that other page. It's ok if this doesn't make sense yet: let's see it in action.

Creating a Custom Exception Class

To start, we need to create a custom authentication exception class. This will serve as the "signal" that we're in this "account not verified" situation.

In the Security/ directory, add a new class: how about AccountNotVerifiedAuthenticationException. Make it extend AuthenticationException. And then... do absolutely nothing else:

... lines 1 - 2
namespace App\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class AccountNotVerifiedAuthenticationException extends AuthenticationException
{
}

This is just a marker class we'll use to hint that we're failing authentication due to an unverified email.

Back in the subscriber, replace the CustomUserMessageAuthenticationException with AccountNotVerifiedAuthenticationException. We don't need to pass it any message:

... lines 1 - 5
use App\Security\AccountNotVerifiedAuthenticationException;
... lines 7 - 13
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
public function onCheckPassport(CheckPassportEvent $event)
{
... lines 18 - 27
if (!$user->getIsVerified()) {
throw new AccountNotVerifiedAuthenticationException();
}
}
... lines 32 - 38
}

If we stopped right now, this won't be very interesting. Logging in still fails, but we're back to the generic message:

An authentication exception occurred

This is because our new custom class extends AuthenticationException... and that's the generic message you get from that class. So this isn't what we want yet, but step 1 is done!

Listening to LoginFailureEvent

For the next step, remember from the debug:event command that one of the listeners we have is for a LoginFailureEvent, which, as the name suggests, is called any time that authentication fails.

Let's add another listener right in this class for that. Say LoginFailureEvent::class set to, how about, onLoginFailure. In this case, the priority won't matter:

... lines 1 - 12
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 17 - 38
public static function getSubscribedEvents()
{
return [
... line 42
LoginFailureEvent::class => 'onLoginFailure',
];
}
}

Add the new method: public function onLoginFailure()... and we know this will receive a LoginFailureEvent argument. Just like before, start with dd($event) to see what it looks like:

... lines 1 - 12
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 17 - 33
public function onLoginFailure(LoginFailureEvent $event)
{
dd($event);
}
... lines 38 - 45
}

So with any luck, if we fail login - for any reason - our listener will be called. For example, if I enter a bad password, yup! It gets hit. And notice that the LoginFailureEvent has an exception property. In this case, it holds a BadCredentialsException.

Now log in with the correct password and... it got hit again. But this time, check out the exception. It's our custom AccountNotVerifiedAuthenticationException! So the LoginFailureEvent object contains the authentication exception that caused the failure. We can use that to know - from this method - if authentication failed due to the account not being verified.

Redirecting when Account is Not Verified

So, if not $event->getException() is an instance of AccountNotVerifiedAuthenticationException, then just return and allow the default failure behavior to do its thing:

... lines 1 - 14
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 17 - 33
public function onLoginFailure(LoginFailureEvent $event)
{
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) {
return;
}
}
... lines 40 - 47
}

Finally, down here, we know that we should redirect to that custom page. Let's... go create that page real quick. Do it in src/Controller/RegistrationController.php. Down at the bottom, add a new method. I'll call it resendVerifyEmail(). Above this, add @Route() with, how about /verify/resend and name equals app_verify_resend_email. Inside, I'm just going to render a template: return $this->render(), registration/resend_verify_email.html.twig:

... lines 1 - 16
class RegistrationController extends AbstractController
{
... lines 19 - 88
/**
* @Route("/verify/resend", name="app_verify_resend_email")
*/
public function resendVerifyEmail()
{
return $this->render('registration/resend_verify_email.html.twig');
}
}

Let's go make that! Inside of templates/registration/, create resend_verify_email.html.twig. I'll paste in the template:

{% extends 'base.html.twig' %}
{% block title %}Verify Email{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<h1 class="h3 mb-3 font-weight-normal">Verify your Email</h1>
<p>
A verification email was sent - please click it to enable your
account before logging in.
</p>
<a href="#" class="btn btn-primary">Re-send Email</a>
</div>
</div>
</div>
{% endblock %}

There's nothing fancy here at all. It just explains the situation.

I did include a button to resend the email, but I'll leave the implementation to you. I'd probably surround it with a form that POSTs to this URL. And then, in the controller, if the method is POST, I’d use the verify email bundle to generate a new link and re-send it. Basically the same code we used after registration.

Anyways, now that we have a functional page, copy the route name and head back to our subscriber. To override the normal failure behavior, we can use a setResponse() method on the event.

Start with $response = new RedirectResponse() - we're going to generate a URL to the route in a minute - then $event->setResponse($response):

... lines 1 - 16
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 19 - 42
public function onLoginFailure(LoginFailureEvent $event)
{
if (!$event->getException() instanceof AccountNotVerifiedAuthenticationException) {
return;
}
$response = new RedirectResponse(
... line 50
);
$event->setResponse($response);
}
... lines 54 - 61
}

To generate the URL, we need a __construct() method - let me spell that correctly - with a RouterInterface $router argument. Hit Alt+Enter and go to "Initialize properties" to create that property and set it:

... lines 1 - 8
use Symfony\Component\Routing\RouterInterface;
... lines 10 - 16
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
private RouterInterface $router;
public function __construct(RouterInterface $router)
{
$this->router = $router;
}
... lines 25 - 61
}

Back down here, we're in business: $this->router->generate() with app_verify_resend_email:

... lines 1 - 16
class CheckVerifiedUserSubscriber implements EventSubscriberInterface
{
... lines 19 - 42
public function onLoginFailure(LoginFailureEvent $event)
{
... lines 45 - 48
$response = new RedirectResponse(
$this->router->generate('app_verify_resend_email')
);
... line 52
}
... lines 54 - 61
}

Donezo! We fail authentication, our first listener throws the custom exception, we look for that exception from the LoginFailureEvent listener... and set the redirect.

Testing time! Refresh and... got it! We're sent over to /verify/resend. I love that!

Next: let's finish this tutorial by doing something super cool, super fun, and... kinda nerdy. Let's add two-factor authentication, complete with fancy QR codes.

Leave a comment!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}