Kevin Bond
Contributed by Kevin Bond in #67563

Controller attributes like #[IsCsrfTokenValid], #[IsSignatureValid], #[Lock] and #[RateLimit] keep your controllers short by moving common checks out of them. When a check fails, Symfony throws an exception and the user gets an error response.

This is fine for APIs, but in HTML applications you may want to handle things differently. For example, if an email confirmation link has expired, you might prefer to show a helpful message and redirect the user. Until now, handling this in the controller meant removing the attribute and doing the check yourself.

In Symfony 8.2, controllers can recover from attribute failures. Add a nullable argument typed with the attribute's exception. If the check fails, Symfony passes the exception to that argument and calls the controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Symfony\Component\HttpFoundation\Exception\SignedUriException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\IsSignatureValid;
// ...

#[IsSignatureValid]
public function verifyEmail(?SignedUriException $signatureError = null): Response
{
    if (null !== $signatureError) {
        $this->addFlash('error', 'This link is invalid or has expired.');

        return $this->redirectToRoute('app_login');
    }

    // ...
}

This works with the following attributes and exceptions:

  • #[IsCsrfTokenValid]: InvalidCsrfTokenException
  • #[IsSignatureValid]: SignedUriException
  • #[Lock]: LockConflictedException
  • #[RateLimit]: RateLimitExceededException

The argument's type determines which failures you handle. Use a subclass to be more specific (e.g. ?ExpiredSignedUriException, a subclass of SignedUriException, handles only expired links) or a union type to handle failures from several attributes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[IsCsrfTokenValid('delete-post')]
#[RateLimit('delete_post')]
public function delete(
    Post $post,
    InvalidCsrfTokenException|RateLimitExceededException|null $failure = null,
): Response {
    if (null !== $failure) {
        $this->addFlash('error', match (true) {
            $failure instanceof InvalidCsrfTokenException => 'Your session expired. Please try again.',
            $failure instanceof RateLimitExceededException => 'Too many deletions. Please try again later.',
        });

        return $this->redirectToRoute('post_show', ['id' => $post->getId()]);
    }

    // ...
}

Controllers without these arguments behave as before. Keep in mind that when the controller receives the exception, it runs anyway, so it must check the argument. That's why #[IsGranted] never passes its failures to the controller: forgetting that check would let users run actions they aren't allowed to.

You can enable the same behavior for your own attributes with the new #[RecoverableAttribute]. See the controller attributes documentation for details.

Published in #Living on the edge