Customize Error Messages & Adding Logout

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

When we fail login, we store the AuthenticationException in the session - which explains what went wrong - and then redirect to the login page:

... lines 1 - 20
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 23 - 65
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse(
$this->router->generate('app_login')
);
}
... lines 74 - 84
}

On that page, we read that exception out of the session using this nice AuthenticationUtils service:

... lines 1 - 9
class SecurityController extends AbstractController
{
... lines 12 - 14
public function login(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('security/login.html.twig', [
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
}

And ultimately, in the template, call the getMessageKey() method to render a safe message that describes why authentication failed:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<form method="post" class="row g-3">
... lines 10 - 11
{% if error %}
<div class="alert alert-danger">{{ error.messageKey }}</div>
{% endif %}
... lines 15 - 29
</form>
</div>
</div>
</div>
{% endblock %}

For example, if we enter an email that doesn't exist, we see:

Username could not be found.

On a technical level, this means that the User object could not be found. Cool... but for us, this isn't a great message because we're logging in via an email. Also, if we enter a valid user - abraca_admin@example.com - with an invalid password, we see:

Invalid credentials.

That's a better message... but it's not super friendly.

Translating the Error Messages?

So how can we customize these? The answer is both simple and... maybe a bit surprising. We translate them. Check it out: over in the template, after messageKey, add |trans to translate it. Pass this two arguments. The first is error.messageData. This isn't too important... but in the translation world, sometimes your translations can have "wildcard" values in them... and you pass in the values for those wildcards here. The second argument is called a "translation domain"... which is almost like a translation category. Pass security:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<form method="post" class="row g-3">
... lines 10 - 11
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
... lines 15 - 29
</form>
</div>
</div>
</div>
{% endblock %}

If you do have a multi-lingual site, all of the core authentication messages have already been translated to other languages... and those translations are available in a domain called security. So by using the security domain here, if we switched the site to Spanish, we would instantly get Spanish authentication messages.

If we stopped now... absolutely nothing would change! But because we're going through the translator, we have the opportunity to "translate" these strings from English to... different English!

In the translations/ directory - which you should automatically have because the translation component is already installed - create a new file called security.en.yaml: security because we're using the security translation domain and en for English. You can also create .xlf translation files - YAML is just easier for what we need to do.

Now, copy the exact error message including the period, paste - I'll wrap it in quotes to be safe - and set it to something different like:

Invalid password entered!

"Invalid credentials.": "Invalid password entered!"

Cool! Let's try it again. Log in as abraca_admin@example.com with an invalid password and... much better! Let's try with a bad email.

Ok, repeat the process: copy the message, go over to the translation file, paste... and change it to something a bit more user-friendly like:

Email not found!

... line 1
"Username could not be found.": "Email not found!"

Let's try it again: same email, any password and... got it!

Email not found.

Okay! Our authenticator is done! We load the User from the email, check their password and handle both success and failure. Booya! We are going to add more stuff to this later - including checking real user passwords - but this is fully functional.

Logging Out

Let's add a way to log out. So... like if the user goes to /logout, they get... logged it! This starts exactly like you expect: we need a route & controller.

Inside of SecurityController, I'll copy the login() method, paste, change it to /logout, app_logout and call the method logout:

... lines 1 - 9
class SecurityController extends AbstractController
{
... lines 12 - 21
/**
* @Route("/logout")
*/
public function logout()
{
... line 27
}
}

To perform the logging out itself... we're going to put absolutely no code in this method. Actually, I'll throw a new \Exception() that says "logout() should never be reached":

... lines 1 - 9
class SecurityController extends AbstractController
{
... lines 12 - 21
/**
* @Route("/logout")
*/
public function logout()
{
throw new \Exception('logout() should never be reached');
}
}

Let me explain. Logging out works a bit like logging in. Instead of putting some logic in the controller, we're going activate something on our firewall that says:

Hey! If the user goes to /logout, please intercept that request, log out the user, and redirect them somewhere else.

To activate that magic, open up config/packages/security.yaml. Anywhere under our firewall, add logout: true:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 25
logout: true
... lines 27 - 39

Internally, this activates a "listener" that looks for any requests to /logout.

Configuring logout

And actually, instead of just saying logout: true, you can customize how this works. Find your terminal and run:

symfony console debug:config security

As a reminder, this command shows you all of your current configuration under the security key. So all of our config plus any default values.

If we run this... and find the main firewall... check out the logout section. All of these keys are the default values. Notice there's one called path: /logout. This is why it's listening to the URL /logout. If you wanted to log out via another URL, you would just tweak this key here.

But since we have /logout here... and that matches our /logout right here, this should work. By the way, you might be wondering why we needed to create a route and controller at all! Great question! We actually don't need a controller, it will never be called. But we do need a route. If we didn't have one, the routing system would trigger a 404 error before the logout system could work its magic. Plus, it's nice to have a route, so we can generate a URL to it.

Ok: let's test this thing! Log in first: abraca_admin@example.com and password tada. Awesome: we are authenticated. Manually go to /logout and... we are now logged out! The default behavior of the system is to log us out and redirect back to the homepage. If you need to customize that, there are a few options. First, under the logout key, you can change target to some other URL or route name.

But we can also hook into the logout process via an event listener, a topic that we'll talk about towards the end of the tutorial.

Next: let's give each user a real password. This will involve hashing passwords, so we can securely store them in the database and then checking those hashed passwords during authentication. Symfony makes both of these easy.

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.4.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
    }
}