Привет, Хабр!

В каждом проекте рано или поздно появляется логика вида «этот пользователь может редактировать этот пост, а тот нет». И начинается: 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. Разберемся, как собрать централизованную систему уведомлений без разрастания кода: единая точка для электронной почты и браузера, маршрутизация по приоритетам и работа с уведомлениями в реальном времени.