| name | symfony:symfony-voters |
| description | Implement granular authorization with Symfony Voters; decouple permission logic from controllers; test authorization separately from business logic |
Symfony Voters
Voters encapsulate authorization logic. Instead of checking permissions in controllers, delegate to voters via isGranted().
Creating a Voter
<?php
namespace App\Security\Voter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class PostVoter extends Voter
{
public const VIEW = 'POST_VIEW';
public const EDIT = 'POST_EDIT';
public const DELETE = 'POST_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE], true)
&& $subject instanceof Post;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return $attribute === self::VIEW && $subject->isPublished();
}
$post = $subject;
return match ($attribute) {
self::VIEW => $this->canView($post, $user),
self::EDIT => $this->canEdit($post, $user),
self::DELETE => $this->canDelete($post, $user),
default => false,
};
}
private function canView(Post $post, User $user): bool
{
if ($post->isPublished()) {
return true;
}
return $this->canEdit($post, $user);
}
private function canEdit(Post $post, User $user): bool
{
return $post->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles(), true);
}
private function canDelete(Post $post, User $user): bool
{
return $post->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles(), true);
}
}
Using Voters
In Controllers
<?php
use App\Entity\Post;
use App\Security\Voter\PostVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class PostController extends AbstractController
{
#[Route('/posts/{id}', methods: ['GET'])]
public function show(Post $post): Response
{
$this->denyAccessUnlessGranted(PostVoter::VIEW, $post);
return $this->render('post/show.html.twig', ['post' => $post]);
}
#[Route('/posts/{id}/edit', methods: ['GET', 'POST'])]
public function edit():
{
->(::, );
}
(, : [])
{
->(::, );
}
}
In Services
<?php
use Symfony\Bundle\SecurityBundle\Security;
class PostService
{
public function __construct(
private Security $security,
) {}
public function updatePost(Post $post, array $data): void
{
if (!$this->security->isGranted(PostVoter::EDIT, $post)) {
throw new AccessDeniedException('Cannot edit this post');
}
}
}
In Twig
{% if is_granted('POST_EDIT', post) %}
<a href="{{ path('post_edit', {id: post.id}) }}">Edit</a>
{% endif %}
{% if is_granted('POST_DELETE', post) %}
<button type="submit">Delete</button>
{% endif %}
API Platform Integration
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
#[ApiResource(
operations: [
new Get(
security: "is_granted('POST_VIEW', object)",
),
new Put(
security: "is_granted('POST_EDIT', object)",
securityMessage: "You can only edit your own posts.",
),
new Delete(
security: "is_granted('POST_DELETE', object)",
securityMessage: "You can only delete your own posts.",
),
],
)]
class Post { }
Complex Voting Logic
With External Dependencies
<?php
final class SubscriptionVoter extends Voter
{
public const ACCESS_PREMIUM = 'ACCESS_PREMIUM';
public function __construct(
private SubscriptionService $subscriptions,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::ACCESS_PREMIUM;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->subscriptions->();
}
}
Resource-less Voters
$this->denyAccessUnlessGranted('ACCESS_PREMIUM');
Multiple Attributes on Same Resource
protected function supports(string $attribute, mixed $subject): bool
{
return str_starts_with($attribute, 'POST_')
&& $subject instanceof Post;
}
Testing Voters
Unit Testing
<?php
use App\Entity\Post;
use App\Entity\User;
use App\Security\Voter\PostVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class PostVoterTest extends TestCase
{
private PostVoter $voter;
protected function setUp(): void
{
$this->voter = new PostVoter();
}
public function testAuthorCanEditOwnPost(): void
{
= ();
= ();
->();
= (, , []);
= ->voter->(, , [::]);
->(::, );
}
{
= ();
= ();
= ();
->();
= (, , []);
= ->voter->(, , [::]);
->(::, );
}
{
= ();
= ();
->([]);
= ();
->();
= (, , []);
= ->voter->(, , [::]);
->(::, );
}
}
Functional Testing
public function testOnlyAuthorCanEditPost(): void
{
$author = UserFactory::createOne();
$otherUser = UserFactory::createOne();
$post = PostFactory::createOne(['author' => $author]);
$this->client->loginUser($author->object());
$this->client->request('PUT', '/api/posts/' . $post->getId());
$this->assertResponseIsSuccessful();
$this->client->loginUser($otherUser->object());
$this->client->request('PUT', '/api/posts/' . $post->getId());
$this->assertResponseStatusCodeSame(403);
}
Best Practices
- One voter per entity or per domain concept
- Use constants for attribute names
- Keep voters pure: No side effects, only return bool
- Test voters in isolation with unit tests
- Combine with roles: Voters can check
ROLE_* internally
- Use securityMessage in API Platform for clear errors