DEV Community

Cover image for Symfony Voters: Authorization Logic That Lives in the Right Layer
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony Voters: Authorization Logic That Lives in the Right Layer


You open a controller and find this:

public function edit(Article $article): Response
{
    $user = $this->getUser();

    if ($article->getAuthor() !== $user
        && !$this->isGranted('ROLE_EDITOR')
        && !($user->isPremium() && $article->isDraft())) {
        throw $this->createAccessDeniedException();
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Three months later the same rule shows up in a console command, an API endpoint, and a Twig template. Slightly different each time. One of them forgot the isPremium() branch. Nobody knows which copy is correct, so nobody touches any of them.

The rule "can this user edit this article" is a business rule. It got scattered across four HTTP-shaped places because Symfony made it easy to write it inline. The voter exists to pull it back into one place. And if you set it up right, that one place can be your domain, not the framework.

What a voter actually is

Symfony's AccessDecisionManager doesn't know your rules. When you call isGranted('EDIT', $article), it asks every registered voter one question: do you have an opinion on this attribute and this subject, and if so, yes or no.

A voter is a class that answers that question. You extend Voter, declare which (attribute, subject) pairs you handle in supports(), and return your verdict in voteOnAttribute().

<?php

namespace App\Security\Voter;

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

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

    public function __construct(private Security $security) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::VIEW], true)
            && $subject instanceof Article;
    }

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

        if (!$user instanceof User) {
            return false;
        }

        return match ($attribute) {
            self::EDIT => $this->canEdit($subject, $user),
            self::VIEW => $this->canView($subject, $user),
        };
    }

    private function canEdit(Article $article, User $user): bool
    {
        if ($this->security->isGranted('ROLE_EDITOR')) {
            return true;
        }

        return $article->getAuthorId() === $user->getId();
    }

    private function canView(Article $article, User $user): bool
    {
        return $article->isPublished()
            || $this->canEdit($article, $user);
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller collapses to one line:

public function edit(Article $article): Response
{
    $this->denyAccessUnlessGranted(ArticleVoter::EDIT, $article);

    // ...
}
Enter fullscreen mode Exit fullscreen mode

That is the standard Symfony refactor, and most tutorials stop here. It's already better: the rule has one home. But look at where that home is. canEdit is sitting in App\Security\Voter. Your business rule now lives in a framework directory, coupled to Security, TokenInterface, and the Doctrine entity.

The voter is an adapter, not the logic

Here is the shift. "Can this user edit this article" is a decision your domain should be able to make with no Symfony in the room. If you can't answer it in a plain unit test without booting the security component, the rule is in the wrong layer.

So write the rule as a domain policy. A plain object, no framework types:

<?php

namespace App\Domain\Article;

final class ArticleAccessPolicy
{
    public function canEdit(Article $article, Editor $editor): bool
    {
        if ($editor->isStaffEditor()) {
            return true;
        }

        return $article->authorId()->equals($editor->id());
    }

    public function canView(Article $article, Editor $editor): bool
    {
        return $article->isPublished()
            || $this->canEdit($article, $editor);
    }
}
Enter fullscreen mode Exit fullscreen mode

Article and Editor here are domain types, not Doctrine entities dressed up as models. authorId() returns a value object. ArticleAccessPolicy has no constructor dependencies you need to mock. You test it with new ArticleAccessPolicy() and two hand-built objects. No container, no kernel, no HTTP.

The voter's job shrinks to translation. It takes the framework's TokenInterface and Doctrine Article, maps them into domain types, and calls the policy:

<?php

namespace App\Security\Voter;

use App\Domain\Article\ArticleAccessPolicy;
use App\Entity\Article as ArticleEntity;
use App\Security\EditorFactory;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

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

    public function __construct(
        private ArticleAccessPolicy $policy,
        private EditorFactory $editors,
    ) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::VIEW], true)
            && $subject instanceof ArticleEntity;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
    ): bool {
        $editor = $this->editors->fromToken($token);

        if ($editor === null) {
            return false;
        }

        $article = $subject->toDomain();

        return match ($attribute) {
            self::EDIT => $this->policy->canEdit($article, $editor),
            self::VIEW => $this->policy->canView($article, $editor),
            default => false,
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

That is the adapter pattern, applied to authorization. The voter is a port into the framework's security stack. The rule lives one layer in, where it belongs, framework-free. The EditorFactory handles the one messy job of turning a security token into a domain Editor, and it's the only place that knows both worlds.

Why this pays off

The scattered if version had a real failure mode: the same rule drifting out of sync across four call sites. The plain voter fixes duplication but leaves the rule welded to Symfony. Pushing the rule into a policy buys you three concrete things.

You can call the exact same decision from a non-HTTP context. A Messenger handler processing a bulk-edit command needs the same check. It doesn't have a request or a security token in the usual shape, but it has a user id and an article. It calls ArticleAccessPolicy directly. No duplicated logic, no fake request.

You can test the rule as a rule. Authorization bugs are the ones that leak data or block paying customers, so the rule deserves fast, exhaustive tests:

<?php

namespace App\Tests\Domain\Article;

use App\Domain\Article\ArticleAccessPolicy;
use PHPUnit\Framework\TestCase;

final class ArticleAccessPolicyTest extends TestCase
{
    private ArticleAccessPolicy $policy;

    protected function setUp(): void
    {
        $this->policy = new ArticleAccessPolicy();
    }

    public function testAuthorCanEditOwnArticle(): void
    {
        $editor = TestEditor::withId(7);
        $article = TestArticle::authoredBy(7);

        self::assertTrue(
            $this->policy->canEdit($article, $editor),
        );
    }

    public function testStrangerCannotEditSomeoneElsesArticle(): void
    {
        $editor = TestEditor::withId(7);
        $article = TestArticle::authoredBy(99);

        self::assertFalse(
            $this->policy->canEdit($article, $editor),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

No KernelTestCase, no WebTestCase, no database. These run in milliseconds, so you write forty of them and cover every branch of the rule. The voter itself gets one thin integration test that proves the wiring, not the logic.

And the rule reads like the business, not like plumbing. When product asks "why can this user edit that," you open ArticleAccessPolicy and read four lines of intent. The token, the Doctrine hydration, the denyAccessUnlessGranted call are all somewhere else, not tangled into the sentence you're trying to read.

Attributes on the entity: convenient, and a trap

Symfony 6.2+ lets you skip the voter class for simple checks with the #[IsGranted] attribute:

#[IsGranted('ARTICLE_EDIT', subject: 'article')]
public function edit(Article $article): Response
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This is fine, and it still routes through your voter. The attribute is a controller-level shortcut for the denyAccessUnlessGranted call; it does not replace the voter or the policy. Keep the decision in the policy. The attribute just moves the entry point from a method body to a method annotation.

The trap is a different attribute pattern: inlining the rule as an expression.

#[IsGranted(new Expression(
    'user === subject.getAuthor() or is_granted("ROLE_EDITOR")'
))]
Enter fullscreen mode Exit fullscreen mode

You've now written a business rule in ExpressionLanguage, as a string, in a controller attribute. It can't be unit tested, can't be reused from a command, and won't show up when you grep for the rule. For a one-off admin toggle, fine. For anything a second call site will ever need, it's the scattered if in a new costume. Push it to the policy.

Where to draw the line

Not every check earns a domain policy. A blunt ROLE_ADMIN gate on an admin dashboard is a role check, not a business rule. Leave it as #[IsGranted('ROLE_ADMIN')] and move on. The policy layer earns its keep when the answer depends on the relationship between a user and a specific thing: ownership, workspace membership, subscription tier crossed with resource state. Those are the rules that get copied, drift, and leak.

A useful test: can you state the rule without mentioning HTTP, tokens, or roles as strings? "The author or a staff editor can edit; anyone can view once it's published." That sentence has no framework in it. So the code that encodes it shouldn't either. The voter carries it across the boundary. The policy holds the meaning.

Where does authorization live in your codebase right now, in the controller, in the voter, or somewhere your domain can actually reach it?


If this was useful

The voter-as-adapter idea is a small instance of a bigger habit: keep the framework at the edge, keep the meaning in the middle. The voter is a port, the policy is the rule, and the two stay on opposite sides of a line you drew on purpose. That line is what Decoupled PHP is about — which concerns belong to Symfony, which belong to your domain, and how to keep the two from bleeding into each other as the app grows.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)