Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Rights to a particular record

Roles answer the question "who is this user". But often you need something else: may they change this particular post? Such rules go into a voter — a class that answers "yes" or "no" for an action and an object.

Creating

php bin/console make:voter PostVoter
// src/Security/Voter/PostVoter.php
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 EDIT = 'POST_EDIT';
    public const DELETE = 'POST_DELETE';
 
    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::DELETE], true)
            && $subject instanceof Post;
    }
 
    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
 
        if (!$user instanceof User) {
            return false;
        }
 
        return $subject->getAuthor() === $user;
    }
}

supports() decides whether the question concerns this voter, voteOnAttribute() gives the answer. A guest is refused at once.

Checking in a controller

#[Route('/blog/{id}/edit', name: 'blog_edit')]
public function edit(Post $post): Response
{
    $this->denyAccessUnlessGranted(PostVoter::EDIT, $post);
 
    // …
}

If the voter says "no", a user gets a 403 error and a guest goes to the login page.

Checking with an attribute

use Symfony\Component\Security\Http\Attribute\IsGranted;
 
#[Route('/blog/{id}/delete', name: 'blog_delete', methods: ['POST'])]
#[IsGranted(PostVoter::DELETE, subject: 'post')]
public function delete(Post $post, EntityManagerInterface $em): Response
{
    $em->remove($post);
    $em->flush();
 
    return $this->redirectToRoute('blog_index');
}

subject is the name of the method argument passed to the voter.

Checking in a template

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

Different rules for different actions

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
    $user = $token->getUser();
 
    if (!$user instanceof User) {
        return false;
    }
 
    return match ($attribute) {
        self::EDIT => $subject->getAuthor() === $user,
        self::DELETE => $subject->getAuthor() === $user && !$subject->isPublished(),
        default => false,
    };
}

An administrator may do anything

A voter is a service and can ask for dependencies. The Security service checks roles:

use Symfony\Bundle\SecurityBundle\Security;
 
public function __construct(private Security $security) {}
 
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
    if ($this->security->isGranted('ROLE_ADMIN')) {
        return true;
    }
 
    // the ordinary rules
}

Common mistake

Checks like if ($post->getAuthor() !== $this->getUser()) scattered across controllers are easy to forget in one of the methods — and then anyone can change someone else's post. The rule should live in one place, the voter, while controllers and templates only ask is_granted.

Thanks for reading!

Donate to the site — we work hard for you.