Adding a 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

Time to create our new Voter class! To do it... we can cheat! Find your terminal and run:

php bin/console make:voter

Call it ArticleVoter. It's pretty common to have one voter per object that you need to decide access for. Let's go check it out src/Security/Voter/ArticleVoter.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 ArticleVoter extends Voter
{
protected function supports($attribute, $subject)
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT', 'VIEW'])
&& $subject instanceof App\Entity\BlogPost;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$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 'EDIT':
// logic to determine if the user can EDIT
// return true or false
break;
case 'VIEW':
// logic to determine if the user can VIEW
// return true or false
break;
}
return false;
}
}

supports()

Nice! Voters are a bit simpler than authenticators: just two methods. Here's how it works: whenever anybody in the system calls isGranted() with any permission attribute string, the supports() method on your voter will be called:

... lines 1 - 8
class ArticleVoter extends Voter
{
protected function supports($attribute, $subject)
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['EDIT', 'VIEW'])
&& $subject instanceof App\Entity\BlogPost;
}
... lines 18 - 40
}

It's our job to decide whether or not our voter knows how to vote.

The $attribute argument will be the string passed to isGranted() and $subject is the second argument - the Article object for us. The example in the generated code is actually pretty good. Let's say that our voter knows how to vote if the $attribute is MANAGE and if the $subject is an instanceOf Article:

... lines 1 - 4
use App\Entity\Article;
... lines 6 - 9
class ArticleVoter extends Voter
{
protected function supports($attribute, $subject)
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['MANAGE'])
&& $subject instanceof Article;
}
... lines 19 - 41
}

If we return false from supports, nothing happens: Our ArticleVoter doesn't vote and it's up to some other voter to handle things. But if we return true, Symfony immediately calls voteOnAttribute():

... lines 1 - 8
class ArticleVoter extends Voter
{
... lines 11 - 18
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$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 'EDIT':
// logic to determine if the user can EDIT
// return true or false
break;
case 'VIEW':
// logic to determine if the user can VIEW
// return true or false
break;
}
return false;
}
}

This is where our logic goes to determine access. If we return true, access will be granted. If we return false, access will be denied.

voteOnAttribute()

Symfony passes us the same $attribute and $subject, as well as something called the $token:

... lines 1 - 4
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
... lines 6 - 8
class ArticleVoter extends Voter
{
... lines 11 - 18
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
... lines 22 - 39
}
}

The token is a lower-level object that you don't see too often. But, you can use it to get access to the User object:

... lines 1 - 4
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
... line 6
use Symfony\Component\Security\Core\User\UserInterface;
class ArticleVoter extends Voter
{
... lines 11 - 18
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
... lines 26 - 39
}
}

I'm going to start in this method by helping my editor. At the top, add /** @var Article $subject */ to say that the $subject variable is an Article object:

... lines 1 - 9
class ArticleVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
/** @var Article $subject */
... lines 23 - 40
}
}

We can safely do this because of the supports() method:

... lines 1 - 9
class ArticleVoter extends Voter
{
protected function supports($attribute, $subject)
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return in_array($attribute, ['MANAGE'])
&& $subject instanceof Article;
}
... lines 19 - 41
}

$subject will definitely be an Article at this point.

Below this, it's pretty common to have a voter that votes on multiple attributes, like EDIT and DELETE. We don't need it, but I'll keep the switch case statement. Our only case is MANAGE:

... lines 1 - 9
class ArticleVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
/** @var Article $subject */
$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 'MANAGE':
... lines 32 - 36
break;
}
... lines 39 - 40
}
}

Excellent! It's time to shine. First, if $subject->getAuthor() == $user then return true:

... lines 1 - 9
class ArticleVoter extends Voter
{
... lines 12 - 19
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
... lines 22 - 28
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'MANAGE':
// this is the author!
if ($subject->getAuthor() == $user) {
return true;
}
break;
}
return false;
}
}

The current user is the author and so access should be granted.

Checking for Roles inside a Voter

If they are not the author, we need to check for ROLE_ADMIN_ARTICLE. But, hmm. We know how to check if a User has a role in a controller: $this->isGranted():

... lines 1 - 11
class ArticleAdminController extends AbstractController
{
... lines 14 - 31
public function edit(Article $article)
{
if (!$this->isGranted('MANAGE', $article)) {
... line 35
}
... lines 37 - 38
}
}

But, how can we check that from inside of a voter? Or, from inside any service?

The answer is.... with the Security service! We actually already know this service! Add a public function __construct() method with a new Security argument: the one from the Symfony component. I'll hit Alt+Enter and select "Initialize Fields" to create that property and set it:

... lines 1 - 7
use Symfony\Component\Security\Core\Security;
... lines 9 - 10
class ArticleVoter extends Voter
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 19 - 53
}

Do you remember where we used this service before? It was inside MarkdownHelper: it's the last argument way over here:

... lines 1 - 9
class MarkdownHelper
{
... lines 12 - 16
private $security;
public function __construct(AdapterInterface $cache, MarkdownInterface $markdown, LoggerInterface $markdownLogger, bool $isDebug, Security $security)
{
... lines 21 - 24
$this->security = $security;
}
... lines 27 - 48
}

We used it because it gives us access to the current User object:

... lines 1 - 9
class MarkdownHelper
{
... lines 12 - 27
public function parse(string $source): string
{
if (stripos($source, 'bacon') !== false) {
$this->logger->info('They are talking about bacon again!', [
'user' => $this->security->getUser()
]);
}
... lines 35 - 47
}
}

But, there's one other thing that the Security class can do. Hold Command or Ctrl and click to open it. It has a getUser() method but it also has an isGranted() method! Awesome! The Security service is the key to get the User or check if the user has access for some permission attribute.

Back down in our voter logic, it's now very simple: if $this->security->isGranted('ROLE_ADMIN_ARTICLE'), then return true. At the bottom, instead of break, return false: if both of these conditions are not met, access denied:

... lines 1 - 10
class ArticleVoter extends Voter
{
... lines 13 - 27
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
... lines 30 - 36
// ... (check conditions and return true to grant permission) ...
switch ($attribute) {
case 'MANAGE':
// this is the author!
if ($subject->getAuthor() == $user) {
return true;
}
if ($this->security->isGranted('ROLE_ADMIN_ARTICLE')) {
return true;
}
return false;
}
return false;
}
}

Ok, let's try this! Move over, refresh and... access granted! Symfony calls the supports() method, that returns true, and because we're logged in as the author, access is granted. Comment out the author check real quick:

// src/Security/Voter/ArticleVoter.php

class ArticleVoter extends Voter
{
    // ...
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        switch ($attribute) {
            case 'MANAGE':
                // this is the author!
                if ($subject->getAuthor() == $user) {
                    //return true;
                }
                // ...
        }

        return false;
    }
}

Try it again. Access denied! Put that back.

@IsGranted with a Subject

Voters are great. And using them to centralize this kind of logic will keep your security code solid. But, there's one small thing that now seems impossible to do. First, open ArticleAdminController. We can actually shorten this to the normal $this->denyAccessUnlessGranted('MANAGE', $article):

... lines 1 - 11
class ArticleAdminController extends AbstractController
{
... lines 14 - 31
public function edit(Article $article)
{
$this->denyAccessUnlessGranted('MANAGE', $article);
dd($article);
}
}

Try it - reload the page. Access granted! This does the exact same thing as before. But... what about using the @IsGranted() annotation?

... lines 1 - 6
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
... lines 8 - 11
class ArticleAdminController extends AbstractController
{
/**
... line 15
* @IsGranted("ROLE_ADMIN_ARTICLE")
*/
public function new(EntityManagerInterface $em)
{
... lines 20 - 26
}
... lines 28 - 37
}

Hmm... now there's a problem: can we use the annotation and still, somehow, pass in the Article object? Actually, yes!

Add @IsGranted(), pass it MANAGE and then a second argument: subject="article":

... lines 1 - 11
class ArticleAdminController extends AbstractController
{
... lines 14 - 28
/**
... line 30
* @IsGranted("MANAGE", subject="article")
*/
public function edit(Article $article)
{
... line 35
}
}

That's it! When you use subject=, you're allowed to pass this the same name as any of the arguments to your controller. This only works because we used the feature that automatically queries for the Article object and passes it as an argument. These two features combine perfectly. But, if you're ever in a situation where your "subject" isn't a controller argument, no worries, just use the normal denyAccessUnlessGranted() code. But, remove it in this case:

... lines 1 - 11
class ArticleAdminController extends AbstractController
{
... lines 14 - 28
/**
... line 30
* @IsGranted("MANAGE", subject="article")
*/
public function edit(Article $article)
{
dd($article);
}
}

Let's... try it! Access granted! That was too easy. Go back to the voter and comment-out the author check again - let's really make sure this is working:

// src/Security/Voter/ArticleVoter.php

class ArticleVoter extends Voter
{
    // ...
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        switch ($attribute) {
            case 'MANAGE':
                // this is the author!
                if ($subject->getAuthor() == $user) {
                    //return true;
                }
                // ...
        }

        return false;
    }
}

Now... yes! Access denied! Go put that code back.

Oh my gosh friends, we did it! We killed this tutorial! We have a great authentication system that allows both login form authentication and API authentication! We have a rich dynamic roles system and a voter system where we can control access with any custom rules. Oh, I love security! I hope you guys are feeling empowered to create your simple, complex, crazy, whatever authentication system you need. As always, if you have questions, ask us down in the comments.

Alright people, seeya next time!

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.9.10
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}