Les Voters Symfony : gérer les permissions au cas par cas
Par Mendel · 01/10/2026 à 00:30
Sur ce blog, plusieurs auteurs peuvent écrire des articles. Chacun doit pouvoir modifier et supprimer ses propres articles, mais pas ceux des autres. L'administrateur, lui, peut tout faire. Cette règle paraît simple, et pourtant les rôles de Symfony ne suffisent pas à l'exprimer. C'est exactement le problème que résolvent les Voters.
Rôles et Voters : deux questions différentes
Un rôle répond à la question « qui es-tu ? ». ROLE_AUTHOR indique qu'un utilisateur est auteur, et lui ouvre l'accès au back-office :
# config/packages/security.yaml
security:
role_hierarchy:
ROLE_AUTHOR: ROLE_USER
ROLE_ADMIN: ROLE_AUTHOR
access_control:
- { path: ^/admin, roles: ROLE_AUTHOR }
Grâce à la hiérarchie, un administrateur est automatiquement auteur, et un auteur est automatiquement utilisateur.
Mais un rôle ne sait rien des objets de l'application. Il ne peut pas répondre à « as-tu le droit de modifier cet article ? ». Pour ça, il faut regarder l'article lui-même et comparer son auteur avec l'utilisateur connecté. C'est le rôle d'un Voter : une classe qui prend une décision à partir de l'utilisateur et d'un objet précis.
Créer un Voter
Le Maker génère la structure de base :
php bin/console make:voter ArticleVoter
Voici la version utilisée sur ce blog :
/**
* @extends Voter<string, Article>
*/
final class ArticleVoter extends Voter
{
public const EDIT = 'ARTICLE_EDIT';
public const DELETE = 'ARTICLE_DELETE';
public function __construct(private AccessDecisionManagerInterface $accessDecisionManager)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE], true)
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])) {
return true;
}
return $subject->getAuthor() === $user;
}
}
Deux méthodes, deux responsabilités :
supports()indique si le Voter est concerné. Symfony interroge tous les Voters à chaque vérification de droits : celui-ci ne répond que pourARTICLE_EDITetARTICLE_DELETEsur unArticle, et reste neutre pour tout le reste ;voteOnAttribute()prend la décision. On refuse les visiteurs non connectés, on autorise l'administrateur, et pour les autres, on compare l'auteur de l'article avec l'utilisateur.
Un détail important : pour vérifier le rôle administrateur, on passe par l'AccessDecisionManager plutôt que de lire $user->getRoles(). Lui seul tient compte de la hiérarchie des rôles. Avec getRoles(), un utilisateur dont le rôle hérite de ROLE_ADMIN serait refusé.
Les constantes EDIT et DELETE évitent de répéter la chaîne 'ARTICLE_EDIT' partout, avec le risque de faute de frappe qui va avec.
Utiliser le Voter
Une fois créé, le Voter est détecté automatiquement. On l'utilise exactement comme un rôle, en passant l'objet concerné en second argument.
Dans un contrôleur :
$this->denyAccessUnlessGranted(ArticleVoter::EDIT, $article);
Avec un attribut, directement sur l'action :
#[IsGranted(ArticleVoter::EDIT, subject: 'article')]
public function edit(Article $article): Response
Dans un template Twig, pour n'afficher un bouton qu'aux personnes autorisées :
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('admin_article_edit', {entityId: article.id}) }}">Modifier l'article</a>
{% endif %}
Et dans EasyAdmin, une seule ligne par action suffit :
public function configureActions(Actions $actions): Actions
{
return $actions
->setPermission(Action::EDIT, ArticleVoter::EDIT)
->setPermission(Action::DELETE, ArticleVoter::DELETE);
}
EasyAdmin appelle alors le Voter pour chaque ligne de la liste : les boutons n'apparaissent que sur les articles autorisés. Et surtout, la protection n'est pas seulement visuelle : un auteur qui taperait directement l'URL de modification d'un article qui n'est pas le sien recevrait une erreur 403.
Tester le Voter
Une règle de sécurité est typiquement le genre de code qu'on ne veut jamais casser par erreur. Bonne nouvelle : un Voter se teste très facilement, sans base de données ni navigateur.
final class ArticleVoterTest extends TestCase
{
public function testAuthorCannotEditSomeoneElsesArticle(): void
{
$author = (new User())->setEmail('auteur@blog.fr');
$other = (new User())->setEmail('autre@blog.fr');
$article = (new Article())->setAuthor($other);
$accessDecisionManager = $this->createStub(AccessDecisionManagerInterface::class);
$accessDecisionManager->method('decide')->willReturn(false);
$voter = new ArticleVoter($accessDecisionManager);
$token = new UsernamePasswordToken($author, 'main', $author->getRoles());
self::assertSame(
VoterInterface::ACCESS_DENIED,
$voter->vote($token, $article, [ArticleVoter::EDIT]),
);
}
}
On remplace l'AccessDecisionManager par une simple doublure qui répond « non admin », pour tester uniquement la logique du Voter. Sur ce blog, une dizaine de tests de ce type vérifient chaque cas : l'auteur, un autre auteur, l'administrateur, le visiteur anonyme, et même l'abstention du Voter pour les attributs qu'il ne gère pas.
Un bon exercice pour s'en convaincre : remplacer la dernière ligne du Voter par return true; et relancer les tests. Ils échouent immédiatement, avec un message qui explique ce qui était attendu. C'est exactement ce qu'on veut le jour où quelqu'un casse la règle par inadvertance.
Conclusion
Les rôles et les Voters sont complémentaires. Les rôles gèrent les accès généraux : qui peut entrer dans le back-office, qui peut modérer les commentaires. Les Voters gèrent les décisions qui dépendent d'un objet précis : cet article est-il le tien, ce commentaire peut-il encore être modifié, ce document est-il partagé avec toi.
En centralisant chaque règle dans une classe dédiée, testée, et utilisée de la même façon dans les contrôleurs, les templates et le back-office, on évite la pire des situations en sécurité : une même règle réécrite à plusieurs endroits, avec le risque qu'une des versions soit oubliée le jour où elle change.
Et vous, utilisez-vous déjà des Voters dans vos projets ? Pour quelles règles ? Dites-le en commentaire !
Commentaires (0)
Aucun commentaire pour le moment.