Qu'est-ce qu'un Voter ?

Un Voter est une classe PHP qui répond à une seule question : "cet utilisateur a-t-il le droit d'effectuer cette action sur cette ressource ?"

Il fait partie du composant symfony/security-core et s'enregistre automatiquement grâce à l'autowiring dès qu'il étend la classe Voter. Son rôle est de décentraliser la logique d'autorisation hors des contrôleurs, des templates et des services — pour la centraliser dans un seul endroit dédié, testable et réutilisable.

En résumé, un Voter c'est le moyen recommandé par Symfony pour gérer des règles d'autorisation fines et métier.

Quand utiliser un Voter ?

Tous les cas d'autorisation ne nécessitent pas un Voter. Voici comment trancher :

Utilisez un Voter quand :

  • L'accès dépend de l'objet lui-même"seul l'auteur peut modifier son article", "seul le propriétaire peut supprimer sa commande".
  • La règle implique plusieurs conditions combinées — rôle ET statut d'abonnement ET état de la ressource.
  • La même règle doit être vérifiée à plusieurs endroits : contrôleur, template Twig, commande console, API REST… Le Voter centralise tout.

N'utilisez pas un Voter quand :

  • C'est une simple vérification de rôle sans logique métier. is_granted('ROLE_ADMIN') suffit amplement — un Voter serait du sur-engineering.
? Règle simple : dès que la question est "qui possède / qui a le droit sur cet objet ?", un Voter est la bonne réponse.

Comment ça fonctionne — l'Access Decision Manager

Quand on appelle isGranted() ou denyAccessUnlessGranted(), Symfony ne décide pas seul. Il délègue la décision à l'Access Decision Manager (ADM), qui consulte tous les Voters enregistrés :

Contrôleur → Access Decision Manager → Voter 1 + Voter 2 + Voter N → ACCESS_GRANTED ou ACCESS_DENIED

Chaque Voter examine la demande et retourne l'un des trois verdicts possibles :

VerdictSignification
ACCESS_GRANTEDL'accès est autorisé
ACCESS_DENIEDL'accès est refusé
ACCESS_ABSTAINCe Voter ne sait pas décider (il laisse les autres trancher)


Les 4 stratégies de décision

L'ADM agrège ensuite les votes selon une stratégie configurable :

  • affirmative (par défaut) — L'accès est accordé si au moins un Voter vote GRANT.
  • consensus — L'accès est accordé si la majorité des Voters vote GRANT.
  • unanimous — L'accès est accordé seulement si tous les Voters votent GRANT.
  • priority — Le premier Voter qui ne s'abstient pas décide.

La stratégie par défaut affirmative est la plus permissive — gardez-la à l'esprit quand plusieurs Voters couvrent le même attribut.

Créer un Voter

La façon la plus rapide est d'utiliser MakerBundle :
php bin/console make:voter PostVoter

Cela génère une classe dans src/Security/Voter/PostVoter.php :

namespace App\Security\Voter;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
// Les attributs que ce Voter gère
const EDIT = 'POST_EDIT';
const DELETE = 'POST_DELETE';

// Symfony appelle cette méthode pour savoir si ce Voter est concerné
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE])
&& $subject instanceof Post;
}

// Contient la logique métier de l'autorisation
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// ... logique métier
}
}

Deux méthodes sont à implémenter :

  • supports() — appelée par Symfony pour savoir si ce Voter doit se prononcer. Elle doit rester légère (pas de requête BDD ici).
  • voteOnAttribute() — contient la logique métier réelle.

La méthode voteOnAttribute en détail

C'est le cœur du Voter. On y reçoit l'attribut demandé, le sujet (l'objet concerné) et le token d'authentification. Le pattern le plus propre avec PHP 8 est match() :

protected function voteOnAttribute(
string $attribute,
mixed $subject, // l'objet Post
TokenInterface $token
): bool {
/** @var Post $post */
$post = $subject;
$user = $token->getUser();

// Utilisateur non connecté → refus immédiat
if (!$user instanceof User) {
return false;
}

return match($attribute) {
self::EDIT => $this->canEdit($post, $user),
self::DELETE => $this->canDelete($post, $user),
default => throw new \LogicException('Attribut non géré'),
};
}

private function canEdit(Post $post, User $user): bool
{
return $user === $post->getAuthor();
}

private function canDelete(Post $post, User $user): bool
{
return $this->security->isGranted('ROLE_ADMIN')
|| $user === $post->getAuthor();
}

Le throw dans default est intentionnel : si un attribut non géré arrive dans voteOnAttribute, c'est un bug qu'on veut voir immédiatement.

Utilisation dans le contrôleur

Deux approches sont disponibles.

Méthode 1

denyAccessUnlessGranted() : lève une AccessDeniedException si l'accès est refusé (Symfony la transforme automatiquement en HTTP 403).

public function edit(Post $post): Response
{
$this->denyAccessUnlessGranted(PostVoter::EDIT, $post);

// Suite de la logique...
}

Méthode 2

Attribut PHP 8 #[IsGranted] : déclaratif, directement sur la méthode du contrôleur.

#[IsGranted(PostVoter::EDIT, subject: 'post')]
public function edit(Post $post): Response
{
// Automatiquement protégé
}

Les deux approches sont équivalentes. La seconde est plus lisible pour les cas simples ; la première est préférable quand la logique d'accès dépend de calculs effectués dans la méthode.

Utilisation dans Twig

La fonction is_granted() est disponible nativement dans les templates. Elle appelle les mêmes Voters que depuis le contrôleur :

{% if is_granted('POST_EDIT', post) %}
<a href="{{ path('post_edit', {id: post.id}) }}">✏️ Modifier</a>
{% endif %}

{% if is_granted('POST_DELETE', post) %}
<button class="btn-danger">?️ Supprimer</button>
{% endif %}

Aucune configuration supplémentaire n'est nécessaire — le Voter est automatiquement invoqué.

Injection de services

Un Voter est un service Symfony à part entière. Il bénéficie de l'autowiring et peut injecter n'importe quel service dans son constructeur :

use Symfony\Bundle\SecurityBundle\Security;

class PostVoter extends Voter
{
public function __construct(
private readonly Security $security,
private readonly PostRepository $postRepository,
private readonly SubscriptionService $subscriptionService,
) {}

private function canView(Post $post, User $user): bool
{
// Admin a toujours accès
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}

// Vérifie un abonnement actif via un service
if ($post->isPremium()) {
return $this->subscriptionService->isActive($user);
}

return true;
}
}

Cette capacité à injecter des services est ce qui rend les Voters si puissants pour des règles métier complexes.

Tester un Voter

Un des grands avantages des Voters est qu'ils sont des classes PHP pures, sans dépendance obligatoire au framework — ce qui les rend très faciles à tester unitairement avec PHPUnit :

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
{
// Injecter les mocks si des services sont nécessaires
$this->voter = new PostVoter();
}

public function testAuteurPeutEditer(): void
{
$auteur = new User();
$post = (new Post())->setAuthor($auteur);
$token = new UsernamePasswordToken($auteur, 'main', []);

$this->assertSame(
VoterInterface::ACCESS_GRANTED,
$this->voter->vote($token, $post, [PostVoter::EDIT])
);
}

public function testAutreUtilisateurNePeutPasEditer(): void
{
$auteur = new User();
$autre = new User();
$post = (new Post())->setAuthor($auteur);
$token = new UsernamePasswordToken($autre, 'main', []);

$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->voter->vote($token, $post, [PostVoter::EDIT])
);
}
}

Si votre Voter injecte des services, remplacez-les par des mocks avec $this->createMock().

Bonnes pratiques

Un Voter par entité.

La méthode supports() est appelée pour chaque Voter sur chaque appel à isGranted(). Une requête SQL ici peut multiplier les appels de façon incontrôlée. Gardez-la légère — instanceof et in_array uniquement.

Nommer clairement les attributs.

Un seul Voter qui gère toutes les entités de l'application enfreint le principe de responsabilité unique et devient très difficile à maintenir et à tester.

Toujours vérifier le type de l'utilisateu

Avant toute logique métier, vérifiez que $token->getUser() est bien une instance de votre classe User. Un utilisateur anonyme retourne null ou une chaîne — ne pas tester ça lève une erreur fatale.

Erreurs à ne pas faire

Faire une requête BDD dans supports()

La méthode supports() est appelée pour chaque Voter sur chaque appel à isGranted(). Une requête SQL ici peut multiplier les appels de façon incontrôlée. Gardez-la légère — instanceof et in_array uniquement.

Créer un Voter "god class"

Un seul Voter qui gère toutes les entités de l'application enfreint le principe de responsabilité unique et devient très difficile à maintenir et à tester.

Oublier le cas "non connecté"

Ne pas vérifier instanceof User avant d'appeler des méthodes sur l'utilisateur lève une erreur fatale pour les visiteurs anonymes.

Ignorer la stratégie de décision.

Avec la stratégie affirmative (par défaut), si deux Voters couvrent le même attribut, un seul GRANT suffit à accorder l'accès — même si l'autre a voté DENY. Réfléchissez à la stratégie adaptée à vos besoins de sécurité.

En résumé

Les Voters Symfony sont un mécanisme élégant pour sortir la logique d'autorisation des contrôleurs. Ils sont simples à créer, faciles à tester, et peuvent être aussi sophistiqués que vos règles métier l'exigent.

La prochaine fois que vous écrivez if ($user === $post->getAuthor()) dans un contrôleur, pensez à encapsuler ça dans un Voter — votre futur vous en sera reconnaissant.