Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Voter

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

To make the security system understand what it means when we check for EDIT access on a Question object, we need a custom voter. And... to help us out, we can generate this.

Find your terminal and run:

symfony console make:voter

Let's call it QuestionVoter. I often have one voter class per object in my system that I need to check access for. And... done!

Adding the Voter Logic

Let's go check it out: src/Security/Voter/QuestionVoter.php:

... lines 1 - 2
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class QuestionVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['POST_EDIT', 'POST_VIEW'])
&& $subject instanceof \App\Entity\Question;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'POST_EDIT':
// logic to determine if the user can EDIT
// return true or false
break;
case 'POST_VIEW':
// logic to determine if the user can VIEW
// return true or false
break;
}
return false;
}
}

As usual, the location of this class makes no difference. The important thing is that our voter implements VoterInterface. Well, not directly... but if you open the core class we extend, you can see that it implements VoterInterface. The point is: as soon as we create a class that implements VoterInterface, each time that the authorization system is called, Symfony will now call our supports() method and basically ask:

Hey! Do you understand how to vote on this $attribute and this $subject?

For us, I'm going to say if in_array($attribute, ['EDIT']). So basically, if the attribute that is passed is equal to EDIT:

... lines 1 - 10
class QuestionVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT'])
... line 17
}
... lines 19 - 40
}

I'm just using an array in case we support other attributes in this voter later - like DELETE.

Anyways, if the $attribute is EDIT and the $subject is an instance of Question, then yes, we know how to vote on this:

... lines 1 - 10
class QuestionVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT'])
&& $subject instanceof \App\Entity\Question;
}
... lines 19 - 40
}

If we return false, it means that our voter will "abstain" from voting. But if we return true, then Symfony calls voteOnAttribute():

... lines 1 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 22 - 39
}
}

Very simply, we need to take the attribute - in our case EDIT - and the $subject - in our case a Question object - and determine whether or not the user should have access by returning true or false.

I'm going to start by adding a few things that will help my editor. First, to get the current User object, you use this $token and call $token->getUser():

... lines 1 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... line 22
$user = $token->getUser();
... lines 24 - 39
}
}

The only problem is that my editor doesn't know that this is an instance of my specific User class: it only knows that it's some UserInterface. To help, I'll add @var User $user above this:

... lines 1 - 5
use App\Entity\User;
... lines 7 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
... lines 24 - 39
}
}

Even better, you could add an if statement to check if $user is not an instance of User and throw an exception:

... lines 1 - 8
use Symfony\Component\Security\Core\User\UserInterface;
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
... lines 28 - 39
}
}

I'll actually do that down here. We know that $subject will be an instance of our Question class. To help our editor know that, say if not $subject is an instanceof Question, then throw a new Exception and just say "wrong type somehow passed":

... lines 1 - 4
use App\Entity\Question;
... lines 6 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
if (!$subject instanceof Question) {
throw new \Exception('Wrong type somehow passed');
}
... lines 32 - 39
}
}

That should never happen, but we're coding defensively. And more importantly, my editor - or static analysis tools like PHPStan - will now know what type the $subject variable is.

Finally, down here, the generated code has a switch-case to handle multiple attributes. I'll remove the second case... and make the first case EDIT. And... I don't even need the break because I'm going to return true if $user is equal to $subject->getOwner():

... lines 1 - 10
class QuestionVoter extends Voter
{
... lines 13 - 19
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/** @var User $user */
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
if (!$subject instanceof Question) {
throw new \Exception('Wrong type somehow passed');
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'EDIT':
return $user === $subject->getOwner();
}
return false;
}
}

Let's try it! Back at the browser, I'm not logged in. So if we go back... to a question page... and click "edit"... access is still denied. Log in with our normal user. And then... access is still denied... which makes sense. We're an admin user... but we are not the owner of this question.

So let's log in as the owner! Go back to the homepage and click into a question. To make it more obvious which user owns this, temporarily, open templates/question/show.html.twig and... down here, after the display name, just to help debug, print question.owner.email:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
... lines 35 - 41
<div class="q-display p-3">
... lines 43 - 44
<p class="pt-4"><strong>--{{ question.owner.displayName }} ({{ question.owner.email }})</strong></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
... lines 53 - 65
</div>
{% endblock %}

And... cool. Copy the email and let's use impersonation! At the end of the URL, add ?_switch_user=, paste that email and... boom! Access is granted thanks to our voter! We can prove it. Jump into the profiler and scroll down. Here it is: access granted for EDIT of this Question object. I love that.

Using the Voter in Twig

Now that we have this cool voter system, we can intelligently hide and show the edit button. Back in show.html.twig, wrap the anchor tag with if is_granted() passing the string EDIT and the question object:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
<div class="d-flex justify-content-between">
... lines 36 - 37
{% if is_granted('EDIT', question) %}
<a href="{{ path('app_question_edit', {
slug: question.slug
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a>
{% endif %}
</div>
... lines 44 - 48
</div>
</div>
</div>
</div>
</div>
</div>
... lines 55 - 67
</div>
{% endblock %}

How cool is that? We should still have access, and... when we refresh, it's still there. But if I exit impersonation... and then click back to the question, it's gone!

Also Allowing Admin Users to Edit

But I have one more challenge. Could we make it so that you can edit a question if you are the owner or if you have ROLE_ADMIN. Sure! To do that, in the voter, we just need to check for that role. To do that, we need a new service.

Add a constructor and autowire the Security service from the Symfony component. I'll hit Alt+Enter and go to "Initialize properties" to set things up:

... lines 1 - 8
use Symfony\Component\Security\Core\Security;
... lines 10 - 11
class QuestionVoter extends Voter
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 20 - 52
}

We talked about this service earlier: we used it to get the currently-authenticated User object from inside a service. It can also be used to check security from within a service.

Even before the switch case, let's add: if $this->security->isGranted('ROLE_ADMIN') then always return true:

... lines 1 - 8
use Symfony\Component\Security\Core\Security;
... lines 10 - 11
class QuestionVoter extends Voter
{
... lines 14 - 27
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
... lines 30 - 40
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
... lines 47 - 48
}
... lines 50 - 51
}
}

So admin users can do anything. Oh, but whooops, I didn't mean to add that exclamation point!

Since we are currently logged in as an admin user.... as soon as we refresh, we have the edit button... and it works. So cool!

Next: Let's add an email confirmation system to our registration form.

Leave a comment!

2
Login or Register to join the conversation
Hanane K. Avatar
Hanane K. Avatar Hanane K. | posted 1 year ago | edited

Hello,
I have a question plz, is there a difference between checking if user has a role in this way if (in_array('ROLE_ADMIN',$user->getRoles())) return true; and using the "Security" service the way u did in this video.
Thanks in advance

Reply

Hey Hanane K.

Before explaining why the first thing you need to know is you should never check for permissions by looking if a role exists in the roles property of a User. Why? Well, there're a few reasons
- The roles hierarchy system won't do its thing. What I mean is if you are looking specifically for the role "ROLE_ADMIN", and the user only has the role "ROLE_SUPER_ADMIN", your check will fail
- Your application may have custom voters to decide whether or not a user has permission to access a resource
- Other security concerns

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}
userVoice