DEV Community

Cover image for Implement traditional auth system in Symfony with less code than ever
Goran Hrženjak for Bornfight

Posted on • Updated on

Implement traditional auth system in Symfony with less code than ever

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
Enter fullscreen mode Exit fullscreen mode

(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",
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now let's make initial User entity:

php bin/console make:user
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Make migration and execute it.

php bin/console make:migration
php bin/console doctrine:migration:migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After that, use:

php bin/console make:registration-form
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Generate a migration for adding this new flag and execute it.

php bin/console make:migration
php bin/console doctrine:migration:migrate
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
        }
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (2)

Collapse
jeremymoorecom profile image
Jeremy Moore

Great write-up. Nice touch adding the ROLE_VERIFIED_USER role. Much easier to check for.

Collapse
gh0c profile image
Goran Hrženjak Author

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.