<?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;
}
}
<?php
namespace App\Controller;
use App\Entity\Comment;
use App\Form\CommentType;
use App\Security\Voter\CommentVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/comments')]
final class CommentController extends AbstractController
{
#[Route('/{id}/edit', name: 'comment_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Comment $comment, EntityManagerInterface $em): Response
{
$this->denyAccessUnlessGranted(CommentVoter::EDIT, $comment);
$form = $this->createForm(CommentType::class, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
return $this->redirectToRoute('comment_show', ['id' => $comment->getId()]);
}
return $this->render('comment/edit.html.twig', [
'comment' => $comment,
'form' => $form,
]);
}
#[Route('/{id}', name: 'comment_delete', methods: ['POST'])]
#[IsGranted(new Expression('is_granted("DELETE", subject)'), subject: 'comment')]
public function delete(Comment $comment, EntityManagerInterface $em): Response
{
$em->remove($comment);
$em->flush();
$this->addFlash('success', 'Comment deleted.');
return $this->redirectToRoute('comment_index');
}
}
security:
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
logout:
path: app_logout
role_hierarchy:
ROLE_ADMIN: ROLE_USER
access_control:
- { path: ^/comments, roles: ROLE_USER }
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
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
Share this code
Here's the card — post it anywhere.