Implement traditional auth system in Symfony with less code than ever
Tags: engineeringmonday, symfony, php, doctrine
Published: 2021-09-06 10:16:16

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](https://stitcher.io/blog/constructor-promotion-in-php-8). Another thing I'll focus on in this guide is replacing annotations with [PHP attributes](https://stitcher.io/blog/attributes-in-php-8). This will also reduce the number of lines of code in our classes every now and then.
As of version [2.9](https://www.doctrine-project.org/2021/05/24/orm2.9.html), 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](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) 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: 
```console
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`:

```diff
{
    "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`
```diff
doctrine:
    orm:
        mappings:
            App:
-               type: annotation
+               type: attribute
``` 

Now let's make initial `User` entity:
```console
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`
```diff
- /**
-  * @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.
```console
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: 
```console
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: 
```diff
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:
```console
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`. 

```diff
- 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.
```diff 
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. 

```diff
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:
```console
composer require symfonycasts/verify-email-bundle
``` 
After that, use: 
```console
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:
```diff
- /**
-  * @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.
```console
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.

```diff
+ 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: 
```diff
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.
```diff
+ 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.