Voters

A voter answers questions like "may this user edit this article" in one class, with a decision strategy and a test that needs no HTTP request.

Syntaxabstract protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool

Roles answer questions about a user. Most real permissions are about a user and an object: only the author may edit this article. A voter is a class that answers one family of such questions.

How a vote happens

When something checks ARTICLE_EDIT against an article, the access decision manager asks every registered voter. The string is the attribute; the object is the subject. Each voter replies granted, denied, or abstain, which means it has no opinion on this attribute or this kind of subject.

The decision strategy combines the replies. The default, affirmative, grants if any voter grants. unanimous grants only if no voter denies. If every voter abstains, access is denied unless allow_if_all_abstain is true.

Writing one

Extending the abstract Voter class is shorthand for implementing VoterInterface. The base class loops over the attributes, calls your supports() to decide whether to speak at all, calls voteOnAttribute(), and maps your true or false onto ACCESS_GRANTED or ACCESS_DENIED, and a false from supports() onto ACCESS_ABSTAIN. They are the same mechanism; the interface is what the manager actually calls.

Any class implementing VoterInterface under src/ is registered automatically. A voter needs nothing from HTTP, so you can build it with new, hand it a token and a subject, and assert on the integer it returns.

Example

Example · php
<?php
// src/Security/Voter/ArticleVoter.php

declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class ArticleVoter extends Voter
{
    public const string VIEW = 'ARTICLE_VIEW';
    public const string EDIT = 'ARTICLE_EDIT';

    protected function supports(string $attribute, mixed $subject): bool
    {
        // false here means abstain: this voter says nothing at all
        return \in_array($attribute, [self::VIEW, self::EDIT], true)
            && $subject instanceof Article;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
        ?Vote $vote = null,
    ): bool {
        $user = $token->getUser();

        if (!$user instanceof User) {
            // the reason is shown in the profiler's security panel
            $vote?->addReason('No logged-in user.');

            return false;
        }

        // supports() has already guaranteed the attribute and the subject type
        return match ($attribute) {
            self::VIEW => $subject->isPublished() || $subject->getAuthor() === $user,
            self::EDIT => $subject->getAuthor() === $user,
        };
    }
}

When to use it

  • Allow only an article's author to edit it, while anyone may read a published one.
  • Deny every write while the application is in a read-only maintenance window.
  • Give a permission rule its own unit test instead of clicking through the interface.

More examples

The same idea written against the interface

A veto voter: it never grants anything, it only denies every attribute ending in _EDIT during a maintenance window, and abstains the rest of the time. It has an effect only under the unanimous strategy, because affirmative would let the ArticleVoter's grant win.

Example · php
<?php
// src/Security/Voter/ReadOnlyWindowVoter.php

declare(strict_types=1);

namespace App\Security\Voter;

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

// implementing the interface directly is the longhand of extending Voter:
// you write the loop and return the constants yourself
final class ReadOnlyWindowVoter implements VoterInterface
{
    public function __construct(private readonly bool $readOnly)
    {
    }

    public function vote(
        TokenInterface $token,
        mixed $subject,
        array $attributes,
        ?Vote $vote = null,
    ): int {
        if (!$this->readOnly) {
            return self::ACCESS_ABSTAIN;
        }

        foreach ($attributes as $attribute) {
            if (\is_string($attribute) && str_ends_with($attribute, '_EDIT')) {
                $vote?->addReason('The site is in read-only mode.');

                return self::ACCESS_DENIED;
            }
        }

        return self::ACCESS_ABSTAIN;
    }
}

Testing the voter directly

vote() is public, so a voter can be exercised as a plain object: build two users, build a token for each, assert on the constant that comes back. No kernel, no database, no browser. This is the cheapest place to prove an ownership rule, and the first place to add a case when a permission bug is reported.

Example · php
<?php
// tests/Security/Voter/ArticleVoterTest.php

declare(strict_types=1);

namespace App\Tests\Security\Voter;

use App\Entity\Article;
use App\Entity\User;
use App\Security\Voter\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

final class ArticleVoterTest extends TestCase
{
    public function testOnlyTheAuthorMayEdit(): void
    {
        $author = new User();
        $stranger = new User();

        $article = new Article();
        $article->setAuthor($author);

        $voter = new ArticleVoter();

        // 'main' is the firewall name; the token needs no request and no session
        $authorToken = new UsernamePasswordToken($author, 'main', $author->getRoles());
        $strangerToken = new UsernamePasswordToken($stranger, 'main', $stranger->getRoles());

        self::assertSame(
            VoterInterface::ACCESS_GRANTED,
            $voter->vote($authorToken, $article, [ArticleVoter::EDIT]),
        );
        self::assertSame(
            VoterInterface::ACCESS_DENIED,
            $voter->vote($strangerToken, $article, [ArticleVoter::EDIT]),
        );
    }
}

Choosing the strategy

Switch to unanimous as soon as you have a voter whose job is to forbid something, such as a suspended account or a read-only window. Under affirmative, that voter's denial is overruled by any other voter that grants, and the result is a permission that silently does nothing.

Example · yaml
# config/packages/security.yaml
security:
    access_decision_manager:
        # affirmative (the default): one grant is enough
        # unanimous: a single denial loses, abstentions are ignored
        strategy: unanimous
        # with unanimous, a check that every voter abstains on is denied
        # unless this is switched on
        allow_if_all_abstain: false

Discussion

  • Be the first to comment on this lesson.