If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOn some sites, after registration, you need to verify your email. You're almost definitely familiar with the process: you register, they send a special link to your email, you click that link and voilà! Your email is verified. If you don't click that link, depending on the site, you might not have access to certain sections... or you may not be able to log in at all. That's what we're going to do.
When we originally ran the make:registration-form
command, it asked us if we wanted to generate an email verification process. If we had said yes, it would have generated some code for us. We said no... so that we could build it by hand, learn a bit more about how it works and customize things a bit.
But before we jump into sending the verification email, inside our User
class, we need some way to track whether or not a user has verified their email. Let's add a new field for that. Run:
symfony console make:entity
Update User
, add an isVerified
property, boolean type, not nullable and... perfect! Head over to the class. Let's see... here we go: $isVerified
:
... lines 1 - 17 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 20 - 56 | |
/** | |
* @ORM\Column(type="boolean") | |
*/ | |
private $isVerified; | |
... lines 61 - 225 | |
public function getIsVerified(): ?bool | |
{ | |
return $this->isVerified; | |
} | |
public function setIsVerified(bool $isVerified): self | |
{ | |
$this->isVerified = $isVerified; | |
return $this; | |
} | |
} |
Let's default this to false
:
... lines 1 - 17 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 20 - 59 | |
private $isVerified = false; | |
... lines 61 - 236 | |
} |
Ok, time for the migration:
symfony console make:migration
Go check that out and... awesome. It looks exactly like we expect:
... lines 1 - 4 | |
namespace DoctrineMigrations; | |
use Doctrine\DBAL\Schema\Schema; | |
use Doctrine\Migrations\AbstractMigration; | |
/** | |
* Auto-generated Migration: Please modify to your needs! | |
*/ | |
final class Version20211012235912 extends AbstractMigration | |
{ | |
public function getDescription(): string | |
{ | |
return ''; | |
} | |
public function up(Schema $schema): void | |
{ | |
// this up() migration is auto-generated, please modify it to your needs | |
$this->addSql('ALTER TABLE user ADD is_verified TINYINT(1) NOT NULL'); | |
} | |
public function down(Schema $schema): void | |
{ | |
// this down() migration is auto-generated, please modify it to your needs | |
$this->addSql('ALTER TABLE user DROP is_verified'); | |
} | |
} |
Run it!
symfony console doctrine:migrations:migrate
Beautiful! Let's do one more thing related to the database. Inside of src/Factory/UserFactory.php
, to make life simpler, set $isVerified
to true
:
... lines 1 - 29 | |
final class UserFactory extends ModelFactory | |
{ | |
... lines 32 - 40 | |
protected function getDefaults(): array | |
{ | |
return [ | |
... lines 44 - 46 | |
'isVerified' => true, | |
]; | |
} | |
... lines 50 - 68 | |
} |
So, by default, users in our fixtures will be verified. But I won't worry about reloading my fixtures quite yet.
Okay: now let's add the email confirmation system! How? By leveraging a bundle! At your terminal, run:
composer require "symfonycasts/verify-email-bundle:1.11.0"
Hey, I know them! This bundle gives us a couple of services that will help generate a signed URL that we will include in the email and then validate that signed URL when the user clicks it. To get this working, open up RegistrationController
. We already have our working register()
method. Now we need one other method. Add public function verifyUserEmail()
. Above this, give it a route: @Route("/verify")
with name="app_verify_email"
:
... lines 1 - 14 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 17 - 53 | |
/** | |
* @Route("/verify", name="app_verify_email") | |
*/ | |
public function verifyUserEmail(): Response | |
{ | |
// TODO | |
} | |
} |
When the user clicks the "confirm email" link in the email that we send them, this is the route and controller that link will take them to. I'm going to leave it empty for now. But eventually, its job will be to validate the signed URL, which will prove that the user did click on the exact link that we sent them.
Up in the register()
action, here is where we need to send that email. As I mentioned earlier, you can do different things on your site based on whether or not the user's email is verified. In our site, we are going to completely prevent the user from logging in until their email is verified. So I'm going to remove the $userAuthenticator
stuff:
... lines 1 - 14 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 17 - 19 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, UserAuthenticatorInterface $userAuthenticator, FormLoginAuthenticator $formLoginAuthenticator): Response | |
{ | |
... lines 22 - 25 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 27 - 39 | |
$userAuthenticator->authenticateUser( | |
$user, | |
$formLoginAuthenticator, | |
$request | |
); | |
return $this->redirectToRoute('app_homepage'); | |
} | |
... lines 48 - 51 | |
} | |
... lines 53 - 60 | |
} |
And replace that with the original redirect to app_homepage
:
... lines 1 - 13 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 18 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, VerifyEmailHelperInterface $verifyEmailHelper): Response | |
{ | |
... lines 21 - 24 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 26 - 47 | |
return $this->redirectToRoute('app_homepage'); | |
} | |
... lines 50 - 53 | |
} | |
... lines 55 - 62 | |
} |
Up top, we can remove some arguments.
Cool. Now we need to generate the signed email confirmation link and send it to the user. To do that, autowire a new service type-hinted with VerifyEmailHelperInterface
. Call it $verifyEmailHelper
:
... lines 1 - 11 | |
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 18 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, VerifyEmailHelperInterface $verifyEmailHelper): Response | |
{ | |
... lines 21 - 53 | |
} | |
... lines 55 - 62 | |
} |
Below, after we save the user, let's generate that signed URL. This... looks a little weird at first. Say $signatureComponents
equals $verifyEmailHelper->generateSignature()
:
... lines 1 - 13 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 18 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, VerifyEmailHelperInterface $verifyEmailHelper): Response | |
{ | |
... lines 21 - 24 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 26 - 35 | |
$entityManager->flush(); | |
$signatureComponents = $verifyEmailHelper->generateSignature( | |
... lines 39 - 42 | |
); | |
... lines 44 - 48 | |
} | |
... lines 50 - 53 | |
} | |
... lines 55 - 62 | |
} |
The first argument is the route name to the verification route. For us, that will be app_verify_email
:
... lines 1 - 13 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 55 | |
/** | |
* @Route("/verify", name="app_verify_email") | |
*/ | |
public function verifyUserEmail(): Response | |
{ | |
... line 61 | |
} | |
} |
So I'll put that here. Then the user's id - $user->getId()
- and the user's email, $user->getEmail()
:
... lines 1 - 13 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 18 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, VerifyEmailHelperInterface $verifyEmailHelper): Response | |
{ | |
... lines 21 - 24 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 26 - 37 | |
$signatureComponents = $verifyEmailHelper->generateSignature( | |
'app_verify_email', | |
$user->getId(), | |
$user->getEmail(), | |
... line 42 | |
); | |
... lines 44 - 48 | |
} | |
... lines 50 - 53 | |
} | |
... lines 55 - 62 | |
} |
These are both used to "sign" the URL, which will help prove that this user did click the link from the email we sent them:
But now we have a decision point. There are two different ways to use the VerifyEmailBundle. The first one is where, when the user clicks this email confirmation link, you expect them to be logged in. In this situation, down in verifyUserEmail()
, we can use $this->getUser()
to figure out who is trying to verify their email and use that to help validate the signed URL.
The other way, which is the way that we're going to use, is to allow the user to not be logged in when they click the confirmation link in their email. With this mode, we need to pass an array as the final argument to include the user id:
... lines 1 - 13 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 18 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, VerifyEmailHelperInterface $verifyEmailHelper): Response | |
{ | |
... lines 21 - 24 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 26 - 37 | |
$signatureComponents = $verifyEmailHelper->generateSignature( | |
'app_verify_email', | |
$user->getId(), | |
$user->getEmail(), | |
['id' => $user->getId()] | |
); | |
... lines 44 - 48 | |
} | |
... lines 50 - 53 | |
} | |
... lines 55 - 62 | |
} |
The whole point of this generateSignature()
method is to generate a signed URL. And thanks to this last argument, that URL will now contain an id
query parameter... which we can use down in the verifyUserEmail()
method to query for the User
. We'll see that soon.
At this point, in a real app, you would take this $signatureComponents
thing, pass it into an email template, use it to render the link and then send the email. But this is not a tutorial about sending emails - though we do have that tutorial. So I'm going to take a shortcut. Instead of sending an email, say $this->addFlash('success')
with a little message that says, "Confirm your email at:" and then the signed URL. You can generate that by saying $signatureComponents->getSignedUrl()
:
... lines 1 - 13 | |
class RegistrationController extends AbstractController | |
{ | |
... lines 16 - 18 | |
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, VerifyEmailHelperInterface $verifyEmailHelper): Response | |
{ | |
... lines 21 - 24 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 26 - 37 | |
$signatureComponents = $verifyEmailHelper->generateSignature( | |
'app_verify_email', | |
$user->getId(), | |
$user->getEmail(), | |
['id' => $user->getId()] | |
); | |
// TODO: in a real app, send this as an email! | |
$this->addFlash('success', 'Confirm your email at: '.$signatureComponents->getSignedUrl()); | |
... lines 47 - 48 | |
} | |
... lines 50 - 53 | |
} | |
... lines 55 - 62 | |
} |
We haven't talked about flash messages. They're basically temporary messages that you can put into the session... then render them one time. I put this message into a success
category. Thanks to this, over in templates/base.html.twig
, right after the navigation - so it's on top of the page - we can render any success
flash messages. Add for flash in app.flashes()
and then look up that key success
. Inside, add div
with alert
, alert-success
and render the message:
<html> | |
... lines 3 - 14 | |
<body | |
... lines 16 - 81 | |
{% for flash in app.flashes('success') %} | |
<div class="alert alert-success">{{ flash }}</div> | |
{% endfor %} | |
{% block body %}{% endblock %} | |
... lines 87 - 89 | |
</body> | |
</html> |
This flash stuff has nothing to do with email confirmation... it's just a feature of Symfony that's most commonly used when you're handling forms. But it's a nice shortcut to help us test this.
Next: let's... do that! Test out our registration form and see what this signed URL looks like. Then we'll fill in the logic to verify that URL and confirm our user.
Hey Mepcuk@
You want to change the lifetime of the token expiration? I realize that we don't document this! At your command line, run:
php bin/console config:dump symfonycasts_verify_email
That should dump the example config that's allowed for this bundle. You'll find that you can (in any YAML file in config/packages - so just create a new one) say:
symfonycasts_verify_email:
lifetime: 7200 # default is 3600
Let me know if this is what you were looking for :).
Cheers!
Hi, I have an issue with this function : verifyUserEmail.
I don't know how write this code:
$user = $userRepository->find();
$request->query->get('id');
OR
$user = $userRepository->find($request->query->get('id'));
Can someone help me please ?
Louis G.
Hey Louis Gellez!
The second is correct:
$user = $userRepository->find($request->query->get('id'));
The $request->query->get('id')
returns the string ?id" query parameter (e.g. if the URL ended in ?id=5, this would return "5"). Then this is passed to the find() method, which finds the User with that id. You'll find this exact code in the "finish" directory of the code download. And once we record this chapter (actually I moved this content to the NEXT chapter today - https://symfonycasts.com/screencast/symfony-security/verify-signed-url ) then you will be able to see it clearly in the code blocks.
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"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
}
}
Hey there!
I'm glad it was useful!
> it seems there isn't functionality regarding resending the verification email after expiration. There seems some open issues (#35, #50) for this enhancement in the repo, but there hasn't been an update on those yet. Do you think such functionality will be added to the bundle in the near future or do we need to implement it ourselves?
I'm not sure. jrushlow helps me maintain that bundle, but we're all pretty busy. The ideal situation would be if someone from the community could add it. But, we have an internal OSS issue tracker, and I'll add this to it :). I agree that it really IS something the bundle needs.
Cheers!