Updated June 2026. Tested on Symfony 7.x and PHP 8.4. Part of the Techalyst Symfony series.

Once you know who a user is, the next question is what they are allowed to do. Symfony has two levels for this. Roles handle broad permissions, can this user reach the admin area, while voters handle the fine-grained, per-object decisions, can this user edit this particular post. Voters are the part worth understanding, because almost every real app needs "you can only edit your own things".

Roles for the broad strokes

A role is a coarse label like ROLE_ADMIN. You check it directly, or lock down whole URL patterns in security.yaml:

$this->denyAccessUnlessGranted('ROLE_ADMIN');
access_control:
    - { path: ^/admin, roles: ROLE_ADMIN }

Roles are perfect for "admins only". They cannot answer "is this user the author of this post", because that depends on the specific object. That is where voters come in.

A voter for per-object decisions

A voter is a class that answers a yes-or-no permission question about a specific object. It has two methods: supports says which questions it handles, and voteOnAttribute makes the decision:

class PostVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, ['EDIT', 'DELETE']) && $subject instanceof Post;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false; // not logged in
        }

        // only the author may edit or delete their post
        return $subject->getAuthor() === $user;
    }
}

supports keeps this voter focused: it only weighs in on EDIT and DELETE of a Post, and ignores everything else. voteOnAttribute holds the actual rule, here, you may act on a post only if you wrote it. Voters are registered automatically, so writing the class is all you do.

Using it

You ask the same question everywhere, and Symfony routes it to the right voter. In a controller, deny access unless granted:

public function edit(Post $post): Response
{
    $this->denyAccessUnlessGranted('EDIT', $post); // throws 403 if not allowed
    // ...
}

And in Twig, hide the edit button when the user is not permitted:

{% if is_granted('EDIT', post) %}
  <a href="{{ path('post_edit', { id: post.id }) }}">Edit</a>
{% endif %}

The beauty is that the rule lives in one place, the voter, and both the controller and the template ask the same EDIT question. Change who can edit a post, and you change it once.

Why this is the right pattern

Scattering ownership checks (if ($post->getAuthor() === $this->getUser())) through controllers and templates is how authorization bugs happen, you forget one spot and a user edits someone else's data. A voter centralises the decision, so every check goes through the same logic. When the rule grows, an editor role can also edit, a post locked after publishing cannot, you extend the voter and every call site gets the new behaviour for free.

Wrapping up

Authorization in Symfony is roles for the broad strokes and voters for per-object decisions. Use roles and access_control for "admins only", and write a voter, supports to scope it and voteOnAttribute for the rule, whenever permission depends on the specific object, like editing your own post. Then ask denyAccessUnlessGranted in controllers and is_granted in Twig, both routed to your voter. Keeping the decision in one place is what keeps authorization correct as the rules grow.