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.