Привет, Хабр!
В каждом проекте рано или поздно появляется логика вида «этот пользователь может редактировать этот пост, а тот нет». И начинается: if ($post->getAuthor() === $currentUser) в контроллерах, в сервисах, в шаблонах. Копипаста расползается, а потом приходит новое требование — «модератор тоже может редактировать, но только в своей категории» — и вы бегаете по двадцати файлам, молясь, что ничего не забыли.
Symfony Voters — механизм, который выносит всю логику авторизации в одно место. Не аутентификации (тип кто ты?), а именно авторизации (что тебе можно?). Разберём, как это работает.
Идея: голосование за доступ
Система авторизации в Symfony построена на голосовании.
Когда вы спрашиваете «можно ли этому пользователю делать X с объектом Y?», фреймворк опрашивает всех зарегистрированных Voter. Каждый отвечает одним из трёх вариантов:
ACCESS_GRANTED — да, разрешаю. ACCESS_DENIED — нет, запрещаю. ACCESS_ABSTAIN — воздержусь, это не мой вопрос.
По умолчанию используется стратегия affirmative: если хотя бы один Voter сказал «да» и никто не сказал «нет» — доступ открыт. Если все воздержались — доступ закрыт. Можно переключить на unanimous (все должны разрешить) или consensus (большинство голосов).
Каждый Voter отвечает за свою область (PostVoter голосует только по постам, OrderVoter — только по заказам) и воздерживается по чужим вопросам.
Простой Voter: CRUD для сущности
Задача: пользователь может просматривать любые посты, но редактировать и удалять только свои. Админ может всё.
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; 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 { // Голосуем только если спрашивают про Post и наш атрибут return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE]) && $subject instanceof Post; } protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token ): bool { $user = $token->getUser(); /** @var Post $post */ $post = $subject; return match ($attribute) { self::VIEW => true, // Просмотр — всем self::EDIT => $this->canEdit($post, $user), self::DELETE => $this->canDelete($post, $user), default => false, }; } private function canEdit(Post $post, ?User $user): bool { if (!$user) return false; // Админ может редактировать любой пост if (in_array('ROLE_ADMIN', $user->getRoles())) { return true; } // Остальные — только свой return $post->getAuthor() === $user; } private function canDelete(Post $post, ?User $user): bool { if (!$user) return false; // Удалять может только автор или админ if (in_array('ROLE_ADMIN', $user->getRoles())) { return true; } return $post->getAuthor() === $user; } }
Два метода и вся суть Voter. supports() отвечает «это мой вопрос?», если передали Post и один из моих атрибутов, я голосую. Если что-то другое воздерживаюсь.
Регистрировать Voter вручную не нужно, autowiring подхватит его автоматически, потому что он наследует Voter.
Использование: три точки входа
В контроллере — самый частый случай:
#[Route('/post/{id}/edit', methods: ['GET', 'POST'])] public function edit(Post $post, Request $request): Response { // Бросит AccessDeniedException (403), если Voter скажет «нет» $this->denyAccessUnlessGranted(PostVoter::EDIT, $post); // Сюда попадём только если доступ разрешён $form = $this->createForm(PostType::class, $post); // ... }
Или через атрибут:
#[Route('/post/{id}/edit')] #[IsGranted(PostVoter::EDIT, subject: 'post')] public function edit(Post $post): Response { // ... }
В Twig-шаблоне — для показа/скрытия кнопок:
{% if is_granted('POST_EDIT', post) %} <a href="{{ path('post_edit', {id: post.id}) }}" class="btn"> Редактировать </a> {% endif %} {% if is_granted('POST_DELETE', post) %} <form method="post" action="{{ path('post_delete', {id: post.id}) }}"> <button type="submit" class="btn btn-danger">Удалить</button> </form> {% endif %}
В сервисе — через инъекцию Security:
use Symfony\Bundle\SecurityBundle\Security; class PostService { public function __construct(private Security $security) {} public function publish(Post $post): void { if (!$this->security->isGranted(PostVoter::EDIT, $post)) { throw new AccessDeniedException('Нет прав на публикацию'); } $post->setPublished(true); // ... } }
Поменяли правило в Voter — поменялось везде: и кнопки в шаблоне скрылись, и контроллер вернёт 403, и сервис бросит исключение.
Voter с зависимостями
Voter — обычный Symfony-сервис. Нужна бизнес-логика посложнее? Инжектите что угодно через конструктор:
class PostVoter extends Voter { public function __construct( private SubscriptionService $subscriptions, private ModerationService $moderation, ) {} protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token ): bool { $user = $token->getUser(); $post = $subject; return match ($attribute) { 'POST_EDIT' => $this->canEdit($post, $user), 'POST_PUBLISH' => $this->canPublish($post, $user), default => false, }; } private function canPublish(Post $post, ?User $user): bool { if (!$user) return false; // Публиковать может автор с активной подпиской // и если пост прошёл модерацию return $post->getAuthor() === $user && $this->subscriptions->hasActivePlan($user) && $this->moderation->isApproved($post); } }
Логика «автор + подписка + модерация» — в одном месте. Тестируется изолированно: мокаете SubscriptionService и ModerationService, проверяете все комбинации.
Voter без объекта
Не всегда авторизация привязана к конкретной сущности. «Может ли пользователь создавать посты вообще?» — тут нет $post, есть только действие.
protected function supports(string $attribute, mixed $subject): bool { // POST_CREATE вызывается без объекта return $attribute === 'POST_CREATE' && $subject === null; } protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token ): bool { $user = $token->getUser(); if (!$user instanceof User) return false; // Создавать могут только пользователи с активной подпиской return $this->subscriptions->hasActivePlan($user); }
Вызов:
$this->denyAccessUnlessGranted('POST_CREATE'); // В шаблоне: {% if is_granted('POST_CREATE') %} <a href="{{ path('post_new') }}">Написать пост</a> {% endif %}
Иерархия ролей и Voter
Не дублируйте проверку ролей в каждом Voter. Symfony поддерживает иерархию ролей:
# config/packages/security.yaml security: role_hierarchy: ROLE_MODERATOR: ROLE_USER ROLE_ADMIN: ROLE_MODERATOR
ROLE_ADMIN автоматически включает ROLE_MODERATOR и ROLE_USER. В Voter проверяете конкретные роли через Security::isGranted():
class PostVoter extends Voter { public function __construct(private Security $security) {} private function canEdit(Post $post, ?User $user): bool { if (!$user) return false; // isGranted учитывает иерархию ролей if ($this->security->isGranted('ROLE_ADMIN')) { return true; } // Модератор может редактировать посты в своей категории if ($this->security->isGranted('ROLE_MODERATOR')) { return $post->getCategory()->getModerator() === $user; } return $post->getAuthor() === $user; } }
Стратегии голосования
По умолчанию affirmative. Конфигурируется в security.yaml:
security: access_decision_manager: strategy: affirmative # хотя бы один «да» (по умолчанию) # strategy: unanimous # все должны разрешить # strategy: consensus # большинство голосов
affirmative подходит в 95% случаев. Каждый Voter отвечает за свою область и воздерживается по чужим. Переключайте стратегию, только если точно понимаете зачем, например, unanimous для систем с несколькими уровнями проверки безопасности.
Тестирование Voter
Voter — обычный класс. Тестируется без фреймворка:
class PostVoterTest extends TestCase { public function testAuthorCanEdit(): void { $user = new User(); $post = new Post(); $post->setAuthor($user); $voter = new PostVoter(); $token = $this->createToken($user); $result = $voter->vote($token, $post, [PostVoter::EDIT]); $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); } public function testNonAuthorCannotEdit(): void { $author = new User(); $stranger = new User(); $post = new Post(); $post->setAuthor($author); $voter = new PostVoter(); $token = $this->createToken($stranger); $result = $voter->vote($token, $post, [PostVoter::EDIT]); $this->assertSame(VoterInterface::ACCESS_DENIED, $result); } public function testAbstainsOnNonPost(): void { $voter = new PostVoter(); $token = $this->createToken(new User()); // Передаём не Post — Voter должен воздержаться $result = $voter->vote($token, new Comment(), [PostVoter::EDIT]); $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); } private function createToken(User $user): TokenInterface { $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); return $token; } }
Проверяйте три состояния: granted, denied, abstain.
Когда Voter — правильный выбор, а когда нет
Voter нужен, когда решение зависит от данных: автор поста, статус заказа, подписка пользователя, отношение между текущим пользователем и объектом.
Voter не нужен, когда доступ определяется только ролью. #[IsGranted('ROLE_ADMIN')] на контроллере — проще и понятнее. Не создавайте AdminVoter, который только проверяет роль.
Не плодите мега-Voter на 500 строк. Один Voter — одна сущность или одна область ответственности. PostVoter, CommentVoter, OrderVoter.
Итого
Voter — одно место для правил авторизации вместо россыпи if-ов по проекту. supports() — мой ли вопрос, voteOnAttribute() — моё решение. Работает в контроллерах, шаблонах, сервисах через единый isGranted().

Если доступы в проекте уже начинают расползаться по коду и ломаться при каждом изменении, это сигнал, что не хватает системного понимания фреймворка. На курсе «Symfony Framework» разбирают безопасность, встроенные инструменты и архитектурные решения, которые позволяют держать эту логику под контролем. Быстро проверить свой уровень через входное тестирование.
Если хотите посмотреть, как такие подходы выглядят на практике, можно начать с бесплатного урока 2 апреля в 20:00 про Symfony Notifier. Разберемся, как собрать централизованную систему уведомлений без разрастания кода: единая точка для электронной почты и браузера, маршрутизация по приоритетам и работа с уведомлениями в реальном времени.
