Granular Permissions Instead of Simple Roles
Role-based access control with ROLE_ADMIN and ROLE_USER rarely suffices in practice. When a user may only edit their own resources, only team members should have access, or permissions depend on the state of the object, the Symfony Security Voter is the right answer.
Table of Contents
- 1. The problem with simple roles
- 2. How Symfony Security Voters work
- 4. Defining voter attributes cleanly
- 5. Ownership checks and object-based permissions
- 6. Team- and organization-based access control
- 7. Using voters in templates and API resources
- 8. Testing voters with PHPUnit
- 9. Roles vs. voters compared
- 10. Summary
- 11. FAQ
1. The problem with simple roles
Roles in Symfony, such as ROLE_ADMIN, ROLE_USER, ROLE_EDITOR, are global permissions. They say: "This user is generally allowed to use feature X." What they cannot express: "This user may only edit resource Y if they are its owner." The naive solution is controller code that checks $resource->getOwner() === $user and throws a 403 response. That works for one place, but when the same check appears in ten controllers, two API endpoints and a Twig template, you end up with code duplication and permission logic that is hard to maintain.
The Symfony Security Voter solves this problem through encapsulation. The permission logic lives in a dedicated class that is implemented once and called everywhere via is_granted(), whether in the controller, in the Twig template, in API Platform security expressions or in services. When the permission rule changes, it is changed in exactly one place. That makes permission logic maintainable, testable and secure, three properties that are hard to achieve with scattered if-else logic in controllers.
2. How Symfony Security Voters work
Symfony's security system calls every registered voter on each isGranted() call and asks it for its decision. Every voter returns one of three values: ACCESS_GRANTED, ACCESS_DENIED or ACCESS_ABSTAIN. A voter that does not recognize the given attribute or subject class returns ACCESS_ABSTAIN, abstaining and leaving the decision to other voters. The AccessDecisionManager aggregates all voter decisions according to a configurable strategy: affirmative (default), consensus or unanimous.
In day-to-day Symfony work, you create voters by extending the abstract base class Voter. This base class already implements the VoterInterface and provides a type-safe interface: supports(string $attribute, mixed $subject) checks whether this voter is responsible for the attribute and subject, and voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token) contains the actual decision logic. By separating "am I responsible" from "is access allowed", the logic stays clearly structured and testable.
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for Article resource permissions.
* Handles EDIT, DELETE, and PUBLISH attributes for Article objects.
*/
final class ArticleVoter extends Voter
{
// Define allowed attributes as class constants for type safety
public const string EDIT = 'ARTICLE_EDIT';
public const string DELETE = 'ARTICLE_DELETE';
public const string PUBLISH = 'ARTICLE_PUBLISH';
private const array ATTRIBUTES = [self::EDIT, self::DELETE, self::PUBLISH];
/**
* Check if this voter handles the given attribute and subject combination.
*/
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::ATTRIBUTES, strict: true)
&& $subject instanceof Article;
}
/**
* Determine access based on attribute, subject state, and current user.
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// Unauthenticated users never get access
if (!$user instanceof User) {
return false;
}
/** @var Article $article */
$article = $subject;
return match ($attribute) {
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
self::PUBLISH => $this->canPublish($article, $user),
default => false,
};
}
private function canEdit(Article $article, User $user): bool
{
// Admins can always edit; otherwise only the author
return $user->isAdmin() || $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// Only admins and the author of unpublished articles can delete
return $user->isAdmin()
|| ($article->getAuthor() === $user && !$article->isPublished());
}
private function canPublish(Article $article, User $user): bool
{
// Publishing requires editor role and the article must be in draft state
return $user->hasRole('ROLE_EDITOR') && $article->isDraft();
}
}
4. Defining voter attributes cleanly
Voter attributes are strings, but it is recommended to define them as class constants. The reason is type safety: if you use ArticleVoter::EDIT instead of the raw string 'ARTICLE_EDIT', PHP checks whether the constant exists. Typos result in a compile error instead of a silent security bug. In the controller you write $this->denyAccessUnlessGranted(ArticleVoter::EDIT, $article), which is clear, type-safe and immediately understandable.
A good convention: attributes are named with the resource name as a prefix to avoid conflicts between multiple voters. ARTICLE_EDIT and POST_EDIT are clearly separated, whereas two voters with the attribute EDIT would react simultaneously, which can lead to unexpected behavior depending on the AccessDecisionManager strategy. In large projects with many entities, it is worth creating a central enum type for all voter attributes, which enables IDE autocompletion and shows all defined permissions at a glance.
5. Ownership checks and object-based permissions
Ownership checks are the most common use case for Symfony Security Voters. The pattern is always the same: a user may modify a resource if they are its owner, or if they have a privileged role that lifts this restriction. In the voter, this logic is implemented with a simple equality check on the owner object: $resource->getOwner() === $user, or via ID comparison $resource->getOwnerId() === $user->getId() for cases where the owner object is not loaded.
A subtle but important nuance: comparing Doctrine entities with === checks object identity in PHP memory. In most cases this is correct, because the same logged-in user token always returns the same object instance from Doctrine's identity map. But when entities come from separate EntityManager contexts, for example a cron job or a test with multiple EntityManagers, === fails even though it is the same database row. In such scenarios, use $resource->getOwnerId() === $user->getId() as a safe alternative.
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TeamMemberRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for Project permissions, ownership, team membership and admin checks.
*/
final class ProjectVoter extends Voter
{
public const string VIEW = 'PROJECT_VIEW';
public const string EDIT = 'PROJECT_EDIT';
public const string DELETE = 'PROJECT_DELETE';
public const string INVITE = 'PROJECT_INVITE_MEMBER';
public function __construct(
private readonly TeamMemberRepository $teamMemberRepository,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE, self::INVITE], strict: 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 => $this->canView($project, $user),
self::EDIT => $this->canEdit($project, $user),
self::DELETE => $this->canDelete($project, $user),
self::INVITE => $this->canInvite($project, $user),
default => false,
};
}
private function isOwner(Project $project, User $user): bool
{
// ID-based comparison: safe across different EntityManager contexts
return $project->getOwnerId() === $user->getId();
}
private function isTeamMember(Project $project, User $user): bool
{
return $this->teamMemberRepository->isMember($project->getId(), $user->getId());
}
private function canView(Project $project, User $user): bool
{
return $project->isPublic()
|| $this->isOwner($project, $user)
|| $this->isTeamMember($project, $user)
|| $user->isAdmin();
}
private function canEdit(Project $project, User $user): bool
{
return $this->isOwner($project, $user) || $user->isAdmin();
}
private function canDelete(Project $project, User $user): bool
{
// Only owner can delete; admin cannot delete other users' projects
return $this->isOwner($project, $user);
}
private function canInvite(Project $project, User $user): bool
{
return $this->isOwner($project, $user) || $user->isAdmin();
}
}
6. Team- and organization-based access control
In multi-tenant SaaS applications, Symfony Security Voters must check whether a user even belongs to the organization that owns a resource. This check is more complex than plain ownership, because it requires database queries, for example whether the current user is an active member of the organization that owns the requested project or document. The voter receives the repository as a dependency and performs the membership check. This is performant as long as the queries are supported by appropriate database indexes.
For hierarchies, where managers can do more than employees and owners more than managers, you implement an explicit hierarchy in the voter: each permission level includes all lower levels. The pattern canEdit() => canView() and canAdmin() => canEdit() ensures that users with higher permissions do not accidentally lose the lower one. In the voteOnAttribute() method, this hierarchy can be elegantly expressed with match and short-circuit evaluation.
7. Using voters in templates and API resources
In Twig templates, is_granted() is available as a global function: {% if is_granted('ARTICLE_EDIT', article) %} calls the same voter as $this->isGranted() in the controller. That means edit buttons, delete links and context-specific UI elements use the same permission logic as the controller. There is no way for the template to show a button that the controller then rejects, the source of truth is always the voter.
In API Platform resources, voter attributes are used in the security expression: security: "is_granted('ARTICLE_EDIT', object)". The object in the expression is the deserialized data object, subject in the voteOnAttribute() method. This full integration makes it possible to share the same permission logic across REST APIs, GraphQL endpoints, Twig templates and Symfony controllers, without code duplication. When the permission rule changes, a single change in the voter is enough.
8. Testing voters with PHPUnit
Symfony Security Voters are ordinary PHP classes and thus excellently testable. A typical unit test creates a voter instance with mocked dependencies, creates a mock token with a given user, and calls vote() directly on the voter. The result, Voter::ACCESS_GRANTED, Voter::ACCESS_DENIED or Voter::ACCESS_ABSTAIN, is checked with PHPUnit assertions. The test matrix should cover all combinations of attribute, user role and object state.
An integration approach tests the complete AccessDecisionManager together with all voters. This means injecting the security service in a KernelTestCase and checking whether $security->isGranted() returns the expected result for various user-object combinations. This approach is slower, but ensures that all registered voters work together correctly and that no voter combination leads to unexpected access or denial.
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use App\Security\Voter\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Unit tests for ArticleVoter, covers all attribute/role/state combinations.
*/
final class ArticleVoterTest extends TestCase
{
private ArticleVoter $voter;
protected function setUp(): void
{
$this->voter = new ArticleVoter();
}
public function testOwnerCanEditOwnArticle(): void
{
$author = $this->createUser(id: 1, roles: ['ROLE_USER']);
$article = $this->createArticle(author: $author, published: false);
$token = new UsernamePasswordToken($author, 'main', $author->getRoles());
$result = $this->voter->vote($token, $article, [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
public function testNonOwnerCannotEditArticle(): void
{
$author = $this->createUser(id: 1, roles: ['ROLE_USER']);
$otherUser = $this->createUser(id: 2, roles: ['ROLE_USER']);
$article = $this->createArticle(author: $author, published: false);
$token = new UsernamePasswordToken($otherUser, 'main', $otherUser->getRoles());
$result = $this->voter->vote($token, $article, [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
public function testAdminCanEditAnyArticle(): void
{
$admin = $this->createUser(id: 99, roles: ['ROLE_USER', 'ROLE_ADMIN']);
$author = $this->createUser(id: 1, roles: ['ROLE_USER']);
$article = $this->createArticle(author: $author, published: true);
$token = new UsernamePasswordToken($admin, 'main', $admin->getRoles());
$result = $this->voter->vote($token, $article, [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
public function testVoterAbstainsForUnknownSubject(): void
{
$user = $this->createUser(id: 1, roles: ['ROLE_USER']);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
// Unknown subject type, voter must abstain, not deny
$result = $this->voter->vote($token, new \stdClass(), [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
}
// Helper methods to create test objects without database
private function createUser(int $id, array $roles): User { /* ... */ }
private function createArticle(User $author, bool $published): Article { /* ... */ }
}
9. Roles vs. voters compared
Roles and Symfony Security Voters solve different problems and complement each other. A direct comparison helps decide which tool is right for the job.
| Criterion | ROLE_* check | Symfony Security Voter | When to use |
|---|---|---|---|
| Object relation | No object relation | Full object access | Voter for object-based permissions |
| Testability | Only indirectly via security | Direct unit tests | Voter clearly ahead |
| Code duplication | Common in controllers | Centralized in the voter | Voter eliminates duplication |
| Complexity | Very low | Medium (own class) | Roles for global access |
| Reusability | Only via copy-paste | Everywhere via is_granted() | Voter in templates, API, services |
The practical recommendation: roles for global access levels such as "is authenticated", "has admin rights", "may enter the admin area". Symfony Security Voters for all object-related permissions. In a well-structured Symfony project, a controller typically checks by role whether the area is accessible, and by voter whether the specific resource may be manipulated. This combination creates clearly structured and fully testable access control.
Mironsoft
Symfony Security, voter architecture and access control systems
Granular access control with Symfony Voters?
We implement well-designed voter architectures for Symfony projects, from simple ownership checks through tenant-based isolation to full API Platform integration.
Security Review
Analysis of existing Symfony projects for scattered permission logic and voter savings potential
Voter Architecture
Voter hierarchies for multi-tenant systems, team permissions and object-based access control
Test Coverage
Complete PHPUnit test suites for all voter combinations of attribute, role and object state
10. Summary
Symfony Security Voters are the right answer to permission requirements that go beyond simple roles. The base class Voter provides a clear structure: supports() for the responsibility check, voteOnAttribute() for the permission logic. Attributes are defined as class constants to enforce type safety. Ownership checks, team membership and state-based permissions are cleanly encapsulated in the voter class.
The investment in a clear voter architecture pays off quickly: the permission logic lives in one place, is testable with PHPUnit and stays consistent across controllers, templates and API endpoints. When a security auditor wants to review the permission logic, they only need to read the voter classes, not examine hundreds of controllers for code duplication. That alone is argument enough for consistently using Symfony Security Voters in any project with more than trivial access rules.
Symfony Security Voters: The Essentials at a Glance
Voter Structure
supports() checks responsibility, voteOnAttribute() contains the logic. Define attributes as class constants for type safety.
Ownership Checks
ID comparison instead of object identity for safe ownership checks even across different EntityManager contexts.
Reusability
Use voters via is_granted() in controllers, Twig templates and API Platform security expressions, one logic, consistent everywhere.
Testability
Test voters directly with PHPUnit, call vote(), check ACCESS_GRANTED/DENIED/ABSTAIN. No HTTP request needed.