php yaml 126 lines · 3 tabs

Enforce Comment Ownership With a Symfony Voter and Controller Authorization Check

Shared by codesnips Aug 2026
3 tabs
<?php

namespace App\Security\Voter;

use App\Entity\Comment;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;

final class CommentVoter extends Voter
{
    public const EDIT = 'EDIT';
    public const DELETE = 'DELETE';

    public function __construct(private AccessDecisionManagerInterface $accessDecisionManager)
    {
    }

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

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

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

        if ($this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])) {
            return true;
        }

        /** @var Comment $subject */
        return $this->isAuthor($subject, $user);
    }

    private function isAuthor(Comment $comment, UserInterface $user): bool
    {
        $author = $comment->getAuthor();

        return null !== $author && $author === $user;
    }
}
3 files · php, yaml Explain with highlit

This snippet shows the canonical Symfony way to enforce that a user may only edit or delete resources they own, without scattering $user->getId() === $comment->getAuthor()->getId() checks across controllers. The pattern centralizes the ownership rule inside a single Voter, then invokes it uniformly through denyAccessUnlessGranted and, alternatively, through a security expression on a route.

In CommentVoter, the class extends Symfony's abstract Voter, which splits authorization into two responsibilities. supports narrows the voter to only the attributes it understands (EDIT and DELETE) and only when the subject is a Comment instance; returning false here means the voter abstains and lets other voters decide. voteOnAttribute holds the real logic: it first requires an authenticated UserInterface, then delegates both attributes to isAuthor, which compares identities. An admin short-circuit via isGranted('ROLE_ADMIN') lets moderators bypass ownership — a common real-world escape hatch. Note the identity comparison uses the entity objects directly, relying on Doctrine identity map semantics, with a fallback that is safe when the author is null.

In CommentController, the ownership rule is consumed two different ways to illustrate the trade-offs. The edit action calls denyAccessUnlessGranted('EDIT', $comment) imperatively; this is flexible because the Comment is already loaded and any extra branching can follow. The delete action instead declares an #[IsGranted(...)] attribute with a security expression, is_granted('DELETE', subject), mapping subject to the route's comment argument. The expression form is declarative and keeps the guard visible at the method signature, but it requires the subject to be resolvable as a controller argument.

The key benefit is a single source of truth: the ownership policy lives in CommentVoter, so tightening it (say, adding a time window for edits) changes one file. access_denied responses become consistent 403s automatically. A subtle pitfall is forgetting that supports must be precise — too broad and the voter votes on unrelated subjects; too narrow and it silently abstains, letting access through if no other voter denies. Developers reach for this pattern whenever authorization depends on the specific object instance rather than a static role.


Related snips

Share this code

Here's the card — post it anywhere.

Enforce Comment Ownership With a Symfony Voter and Controller Authorization Check — share card
Link copied