PHP 8 introduced some new concepts and really helpful syntax features.
To significantly reduce the boilerplate code, whenever possible, we can use Constructor property promotion. Another thing I'll focus on in this guide is replacing annotations with PHP attributes. This will also reduce the number of lines of code in our classes every now and then.
As of version 2.9, Doctrine supports using PHP 8 Attributes as a new driver for mapping entities.
Not only will we need fewer lines of code than ever for this project, but also we will need to write less of that code ourselves than ever. I’m emphasizing this because we’ll heavily rely on the Maker bundle which will generate the majority of files and actual app logic for the project.
At the time of writing this post, Maker bundle still didn’t fully adopt all new PHP possibilities and some adjustments will be done manually.
The goal of the app is to provide a basic traditional authentication system with registration and login features and email verification.
App will have 3 sections: public section accessible by everyone, profile section available to all logged in users, and content section available only to verified users.
Account verification will be done by simply clicking a link in the verification email.
Create a new project:
composer create-project symfony/website-skeleton my_new_app
(or use Symfony CLI). I’m using Symfony 5.3.7.
Make sure to update required PHP version in composer.json
:
{
"require": {
- "php": ">=7.2.5",
+ "php": "^8.0",
}
}
Update Doctrine configuration - use attributes instead of annotations! Without this, generation migrations will not work.
config/packages/doctrine.yaml
doctrine:
orm:
mappings:
App:
- type: annotation
+ type: attribute
Now let's make initial User
entity:
php bin/console make:user
Answer [yes]
or select defaults for all questions in the wizard.
This should create src/Entity/User.php
and src/Repository/UserRepository.php
and update config/packages/security.yaml
files.
Symfony Maker bundle still doesn’t support attributes, but generated entities will still save us a lot of time. We can replace annotations with attributes ourselves.
Use attributes and property types to reduce the amount of code.
src/Entity/User.php
- /**
- * @ORM\Entity(repositoryClass=UserRepository::class)
- */
+ #[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
- /**
- * @ORM\Id
- * @ORM\GeneratedValue
- * @ORM\Column(type="integer")
- */
- private $id;
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column]
+ private int $id;
- /**
- * @ORM\Column(type="string", length=180, unique=true)
- */
- private $email;
+ #[ORM\Column(length: 180, unique: true)]
+ private string $email;
- /**
- * @ORM\Column(type="json")
- */
+ #[ORM\Column(type: 'json)]
private $roles = [];
- /**
- * @var string The hashed password
- * @ORM\Column(type="string")
- */
- private $password;
+ #[ORM\Column]
+ private string $password;
}
Make migration and execute it.
php bin/console make:migration
php bin/console doctrine:migration:migrate
Generate simple controllers: PublicController
, ProfileController
, ContentController
. This will add routes /public
, /profile
and /content
. You can do this with Maker as well:
php bin/console make:controller
Rename route names for consistency by prefixing them with: app_
.
All 3 routes should be available to anyone at this stage.
Add role hierarchy and access rules to config/packages/security.yaml
to achieve what's explained above:
security:
+ role_hierarchy:
+ ROLE_VERIFIED_USER: [ ROLE_USER ]
access_control:
+ - { path: ^/content, roles: ROLE_VERIFIED_USER }
+ - { path: ^/profile, roles: ROLE_USER }
Now you should be getting 401 Unauthorized error if you try to access /profile
or /content
.
Make the login authentication:
php bin/console make:auth
Select [1] Login form authenticator
, call it LoginFormAuthenticator
, confirm the controller name: SecurityController
and accept adding the logout route.
This will update the config/packages/security.yaml
file by adding a logout route and create authenticator, controller and login form files.
First of all, in login form Twig template, replace deprecated user.username
with user.userIdentifier
.
- You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a>
+ You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
In src/Controller/SecurityController.php
we can replace routes defined by annotations with those defined by attributes.
class SecurityController extends AbstractController
{
- /**
- * @Route("/login", name="app_login")
- */
+ #[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
- /**
- * @Route("/logout", name="app_logout")
- */
+ #[Route('/logout', name: 'app_logout')]
public function logout()
}
Thanks to constructor property promotion in PHP 8, we can rewrite the constructor in src/Security/LoginFormAuthenticator.php
. While at it, add proper response in onAuthenticationSuccess
method:
after successful login, redirect to app_profile
route.
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
- private UrlGeneratorInterface $urlGenerator;
-
- public function __construct(UrlGeneratorInterface $urlGenerator)
- {
- $this->urlGenerator = $urlGenerator;
- }
+ public function __construct(private UrlGeneratorInterface $urlGenerator)
+ {
+ }
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
- throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
+ return new RedirectResponse($this->urlGenerator->generate('app_profile'));
}
}
Note: If you're using a Symfony plugin in your code editor and it's complaining it can't find the route with the given name, make sure you've prefixed those routes in controllers as suggested above.
Notice how slim this authenticator became in comparison to what it used to look in older versions of Symfony.
Let’s implement registration logic.
Should we write all of this ourselves? Nope. Maker bundle to the rescue again.
First of all, let’s require another bundle, one for handling email verification logic:
composer require symfonycasts/verify-email-bundle
After that, use:
php bin/console make:registration-form
Select defaults except the one for including user ID in the link - answer yes
on that prompt; and select app_profile
as a route to redirect to after registration.
It's possible that Maker will warn you no Guard authenticators were found and users won't be automatically authenticated after registering. Ignore this for now, we'll implement a solution for this at the end.
The command will change User
entity and create confirmation email and registration form Twig templates as well as create a RegistrationController
, RegistrationFormType
and EmailVerifier
helper.
Update src/Entity/User.php
first:
- /**
- * @UniqueEntity(fields={"email"}, message="There is already an account with this email")
- */
+ #[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
- /**
- * @ORM\Column(type="boolean")
- */
- private $isVerified = false;
+ #[ORM\Column(options: ['default' => false])]
+ private bool $isVerified = false;
}
Generate a migration for adding this new flag and execute it.
php bin/console make:migration
php bin/console doctrine:migration:migrate
Symfony recommends putting as little logic as possible in controllers. That’s why complex forms will be moved to dedicated classes instead of defining them in controller actions. Maker did that for us.
There are few things to change in RegistrationController
- use constructor property promotion and replace deprecated UserPasswordEncoderInterface
with UserPasswordHasherInterface
.
At the end of the verification process, redirect to the content page.
+ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
- use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
class RegistrationController extends AbstractController
{
- private $emailVerifier;
-
- public function __construct(EmailVerifier $emailVerifier)
- {
- $this->emailVerifier = $emailVerifier;
- }
+ public function __construct(private EmailVerifier $emailVerifier)
+ {
+ }
- public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder): Response
+ public function register(Request $request, UserPasswordHasherInterface $passwordHasher): Response
{
$user->setPassword(
- $passwordEncoder->encodePassword(
- $user,
- $form->get('plainPassword')->getData()
- )
+ $passwordHasher->hashPassword($user, $form->get('plainPassword')->getData())
);
}
public function verifyUserEmail(Request $request, UserRepository $userRepository): Response
{
- return $this->redirectToRoute('app_register');
+ return $this->redirectToRoute('app_content');
}
}
We want to shorten that constructor in the EmailVerifier class and also add proper user roles after email verification:
class EmailVerifier
{
- private $verifyEmailHelper;
- private $mailer;
- private $entityManager;
-
- public function __construct(VerifyEmailHelperInterface $helper, MailerInterface $mailer, EntityManagerInterface $manager)
- {
- $this->verifyEmailHelper = $helper;
- $this->mailer = $mailer;
- $this->entityManager = $manager;
- }
+ public function __construct(
+ private VerifyEmailHelperInterface $verifyEmailHelper,
+ private MailerInterface $mailer,
+ private EntityManagerInterface $entityManager
+ ) {
+ }
public function handleEmailConfirmation(Request $request, UserInterface $user): void
{
$user->setIsVerified(true);
+ $user->setRoles(['ROLE_VERIFIED_USER']);
}
}
In later versions of Maker bundle where dependency to Guards will be dropped, this might be resolved, but for now we have to implement logging user in after registration manually.
Not a huge deal really. Just inject the UserAuthenticatorInterface
and our authenticator in the register
method and authenticate the user before returning the redirect response.
+ use App\Security\LoginFormAuthenticator;
+ use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
class RegistrationController extends AbstractController
{
- public function register(Request $request, UserPasswordHasherInterface $passwordHasher): Response
- {
+ public function register(
+ Request $request,
+ UserPasswordHasherInterface $passwordHasher,
+ UserAuthenticatorInterface $authenticator,
+ LoginFormAuthenticator $formAuthenticator
+ ): Response {
if ($form->isSubmitted() && $form->isValid()) {
// ...
+ $authenticator->authenticateUser($user, $formAuthenticator, $request);
return $this->redirectToRoute('app_profile');
}
}
That's basically it for the scope of this guide 🤓
Try accessing /profile
or /content
route. You should be redirected to the login page. If you still haven't, it's time to register as a new user.
Go to /register
and enter the desired email and password. You should be logged in automatically and redirected to /profile
. Accessing /content
is still not possible.
You should have received a verification email. For this to work out of the box, you only need to set up the MAILER_DSN
environmental variable according to your mailing server.
After clicking the confirmation link, flag is_verified
will be set, user role ROLE_VERIFIED_USER
added and you'll be able to access /content
.
You can render flash messages or add password reset feature (by including another great bundle: symfonycasts/reset-password-bundle
) or maybe implement social logins as the next step.
Let me know in the comments if code snippets with diffs weren't clear enough or if you have any other questions.
Top comments (2)
Great write-up. Nice touch adding the ROLE_VERIFIED_USER role. Much easier to check for.
Thanks for your feedback and support!
I agree this is a good example where introducing a new user role is very practical, as long as hole hierarchy is "vertical" as it is here.