Roles and Access Control With Voters
Roles and Access Control With Voters
~17 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
access_control from chapter 26 handles COARSE access rights (logged in or not) – for our actual goal from chapter 5 ("only project members see their own projects"), we need FINE-GRAINED, object-based checks: voters.
Why ROLE_USER alone isn't enough
ROLE_USER only answers "is this user LOGGED IN?" – NOT "is THIS user allowed to see THIS SPECIFIC project?". That question depends on the CONCRETE data (is the user a member of THIS project?), not a static role.
Generating a voter
php bin/console make:voter ProjectVoter<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Project;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProjectVoter extends Voter
{
public const string VIEW = 'PROJECT_VIEW';
public const string EDIT = 'PROJECT_EDIT';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT], true)
&& $subject instanceof Project;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Project $project */
$project = $subject;
return match ($attribute) {
self::VIEW => $project->getMembers()->contains($user),
self::EDIT => $project->getMembers()->contains($user),
default => false,
};
}
}supports() and voteOnAttribute() in detail
supports()– a quick pre-check: "does THIS voter even feel responsible for this attribute/object combination?" Symfony only callsvoteOnAttribute()when this returnstrue.voteOnAttribute()– the actual LOGIC:true= access granted,false= denied.
self::VIEW/self::EDIT as named constants instead of raw strings prevents typos and makes it visible via IDE autocompletion WHICH attributes this voter even supports.
Using the voter in the controller
use App\Security\Voter\ProjectVoter;
#[Route('/projects/{id}', name: 'project_show', requirements: ['id' => '\d+'])]
public function show(int $id, ProjectRepository $projectRepository): Response
{
$project = $projectRepository->find($id);
if ($project === null) {
throw $this->createNotFoundException();
}
$this->denyAccessUnlessGranted(ProjectVoter::VIEW, $project);
return $this->render('project/show.html.twig', ['project' => $project]);
}denyAccessUnlessGranted() (from AbstractController) calls voteOnAttribute() INTERNALLY and AUTOMATICALLY throws an AccessDeniedException (Symfony turns this into a 403 error page) if access is denied – NO manual if needed.
Checking voter attributes in Twig
{% if is_granted('PROJECT_EDIT', project) %}
<a href="{{ path('project_edit', {id: project.id}) }}">Edit</a>
{% endif %}is_granted() is Twig's counterpart to denyAccessUnlessGranted() – here without an exception, but for CONDITIONALLY displaying individual UI elements (only showing the edit link if it's actually ALLOWED to be used).
Simple role checks without a voter
For simple role checks (with no object relation), a voter is OVERKILL – #[IsGranted] as an attribute on the method is enough:
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/users', name: 'admin_user_index')]
#[IsGranted('ROLE_ADMIN')]
public function index(): Response
{
// Only reachable for admins
}Rule of thumb: voter vs. #[IsGranted] vs. access_control
| Tool | Use case |
|---|---|
| access_control (security.yaml) | COARSE, URL-pattern-based rules – e.g. "the entire /admin area needs ROLE_ADMIN". |
| #[IsGranted('ROLE_...')] | Simple role check on ONE controller method, with NO object relation. |
| Voter | OBJECT-RELATED checks ("does THIS project belong to THIS user?") – like our example. |
Tipp: A widespread anti-pattern: checking permission logic directly IN the controller with nested if statements. Voters keep this logic in EXACTLY one place, testable in isolation (chapters 42-43), and reusable across MULTIPLE controllers.