Securing Controllers
#[IsGranted] on a controller method or class, the denyAccessUnlessGranted longhand it stands for, and when to answer 404 instead of 403.
#[IsGranted(string|Expression $attribute, string|array|null $subject = null, ?string $message = null, ?int $statusCode = null, ?int $exceptionCode = null, array|string $methods = [])]Path rules cover whole sections of a site. A single action often needs a check of its own, next to the code it protects, where you can see it while reading the method. That is what #[IsGranted] is for.
The attribute and its longhand
Put #[IsGranted('ROLE_EDITOR')] on a method and the request is checked before the method runs. Put it on the class and it applies to every action in it; class and method attributes both apply, and the attribute is repeatable, so two on one method both have to pass.
The shorthand desugars to one line of code: $this->denyAccessUnlessGranted('ROLE_EDITOR'), inherited from AbstractController. Both raise AccessDeniedException, which the firewall turns into a 403 response, or into a redirect to the login page when nobody is logged in. Use the longhand when the check depends on something you have to compute first.
The subject argument is the name of a controller argument as a string, not the variable: #[IsGranted('ARTICLE_EDIT', 'article')] passes $article to whatever decides the ARTICLE_EDIT attribute.
403 or 404
A 403 confirms the resource exists. For anything whose existence is itself private, answer 404 instead. statusCode: 404 does that, and $this->createNotFoundException() is the longhand. Both hide the resource; neither hides it from someone who can already see it listed elsewhere.
Symfony 8 has no annotation support. @IsGranted in a docblock is a comment and protects nothing.
Example
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/articles')]
// applies to every action below, in addition to their own attributes
#[IsGranted('ROLE_USER')]
final class ArticleController extends AbstractController
{
// 'article' is the NAME of the argument below, so $article becomes the subject
#[Route('/{id}/edit', name: 'app_article_edit', methods: ['GET'])]
#[IsGranted('ARTICLE_EDIT', 'article', statusCode: 404)]
public function edit(Article $article): Response
{
return $this->render('article/edit.html.twig', ['article' => $article]);
}
#[Route('/{id}', name: 'app_article_show', methods: ['GET'])]
public function show(Article $article): Response
{
// the longhand: the same check the attribute above performs
$this->denyAccessUnlessGranted('ARTICLE_VIEW', $article);
return $this->render('article/show.html.twig', ['article' => $article]);
}
}
// GET /articles/7/edit as the article's author: 200, the edit form.
// As another logged-in user: 404, because statusCode: 404 hides the draft.
// Not logged in: 302 to the login page, from the class-level ROLE_USER check.When to use it
- Require a role for every action in an admin controller with one attribute on the class.
- Pass the entity loaded from the URL into a permission check before editing it.
- Answer 404 instead of 403 for a private resource whose URL is guessable.
More examples
A check you have to compute first
An attribute is evaluated before the method body, so it cannot depend on the entity's state. When the attribute to check is only known at runtime, write the longhand. GET on a settled invoice returns 200 with the PDF; on someone else's draft it returns 403.
<?php
// src/Controller/InvoiceController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Invoice;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class InvoiceController extends AbstractController
{
#[Route('/invoices/{id}/pdf', name: 'app_invoice_pdf', methods: ['GET'])]
public function pdf(Invoice $invoice): Response
{
// a settled invoice is readable by the whole team, a draft only by its owner
$attribute = $invoice->isSettled() ? 'INVOICE_VIEW' : 'INVOICE_EDIT';
// the third argument is the message recorded for the logs and the profiler,
// not text sent to the browser
$this->denyAccessUnlessGranted($attribute, $invoice, 'Not your invoice.');
return $this->file($invoice->getPdfPath());
}
}Two attributes, and one HTTP method
#[IsGranted] is repeatable, which is how you require a role and an object-level permission together. The methods argument restricts which requests the check applies to, letting one action be read by anyone and written only by admins. Be deliberate with it: requests using another method skip that check entirely.
<?php
// src/Controller/Admin/UserController.php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/users')]
final class UserController extends AbstractController
{
// repeated attributes are combined: BOTH have to pass
#[Route('/{id}', name: 'app_admin_user_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('USER_DELETE', 'user')]
public function delete(User $user): Response
{
// ... remove the user, then redirect
return $this->redirectToRoute('app_admin_user_index');
}
// methods: narrows the check itself, so GET is readable without ROLE_ADMIN
#[Route('/{id}/notes', name: 'app_admin_user_notes', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_ADMIN', methods: 'POST')]
public function notes(User $user): Response
{
return $this->render('admin/user/notes.html.twig', ['user' => $user]);
}
}Hiding existence from the client
isGranted() returns a boolean instead of raising an exception, so you choose the response yourself. Use this when the slug is guessable and the existence of a team is confidential: a 404 makes a probe for /teams/acme-corp indistinguishable from a typo.
<?php
// src/Controller/TeamController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Team;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class TeamController extends AbstractController
{
public function __construct(private readonly Security $security)
{
}
#[Route('/teams/{slug}', name: 'app_team_show', methods: ['GET'])]
public function show(Team $team): Response
{
// a 403 here would confirm that a team with this slug exists
if (!$this->security->isGranted('TEAM_VIEW', $team)) {
throw $this->createNotFoundException();
}
return $this->render('team/show.html.twig', ['team' => $team]);
}
}
Discussion