Ever needed to retrieve a list of users from a Doctrine repository having a single role? Like, get all users with ROLE_ADMIN or ROLE_USER?

If you go the “easy way” filtering with LIKE you can expect some surprises: what will this function returns when $role = 'ADMIN' and you both have roles named ROLE_ADMIN and ROLE_SUPER_ADMIN ?

<?php
    public function findByRoleWrongWay(string $role)
    {
        return $this->createQueryBuilder('u')
            ->andWhere('u.roles LIKE :role')
            ->setParameter('role', 'ROLE_%"' . $role . '"%')
            ->getQuery()
            ->getResult();
    }

Yes, you get an array of users either with role ROLE_ADMIN or ROLE_SUPER_ADMIN.

Default User Symfony entity

If you, like me, use Symfony Maker Bundle and create your User entity with bin/console make:user you’ll get an entity similar to this one1:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

class User implements UserInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="json")
     */
    private $roles = [];
    
    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }
}

For my application an entity like this doesn’t work for one simple reason: ROLE_USER is not saved in User table, and I cannot filter users by this value; a new user persisted to DB will result with an empty roles value, at least in MySQL 5.7.

$ bin/console doctrine:query:dql "select u.roles from App\Entity\User u where u.id='001cf6c4-2d97-4be8-8b62-851ae3265432'"

/Users/manuel/Sites/test/vendor/doctrine/common/lib/Doctrine/Common/Util/Debug.php:55:
array (size=1)
  0 =>
    array (size=1)
      'roles' =>
        array (size=0)
          empty

I have 2 options here:

  • Don’t use ROLE_USER, but declare a new role that inherits it
  • Persist ROLE_USER to database

Let’s analyze both.

A new role for base user

Symfony’s roles follow a hierarchy, so declaring a new role and using it as the base role can be done by modifying config/packages/security.yaml

# config/packages/security.yaml
security:
    # ...

    role_hierarchy:
        ROLE_BASE_USER:   ROLE_USER
        ROLE_ADMIN:       ROLE_BASE_USER
        ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

Use the new base role where appropriate, like in isGranted checks

<?php
    $hasAccess = $this->isGranted('ROLE_BASE_USER');
    $this->denyAccessUnlessGranted('ROLE_BASE_USER');

(Actually, the user gets 2 roles: ROLE_USER and ROLE_BASE_USER)

Persisting ROLE_USER to database

My favourite solution is to persist the role in database by editing User entity, assigning a default role for every new user created:

--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -27,8 +27,10 @@ class User implements UserInterface

     /**
      * @ORM\Column(type="json")
+     *
+     * guarantee every new user at least has ROLE_USER
      */
-    private $roles = [];
+    private $roles = ['ROLE_USER'];

     /**
      * @var string The hashed password
@@ -74,8 +76,6 @@ class User implements UserInterface
     public function getRoles(): array
     {
         $roles = $this->roles;
-        // guarantee every user at least has ROLE_USER
-        $roles[] = 'ROLE_USER';

         return array_unique($roles);
     }

Now, when I create a new user, executing the same DQL as before, it lists the user role

$ bin/console doctrine:query:dql "select u.roles from App\Entity\User u where u.id='0092b32b-7e54-4a83-a374-275c18f1a335'"

/Users/manuel/Sites/test/vendor/doctrine/common/lib/Doctrine/Common/Util/Debug.php:55:
array (size=1)
  0 =>
    array (size=1)
      'roles' =>
        array (size=1)
          0 => string 'ROLE_USER' (length=9)

When I add admin role to this user I’ll see both roles:

$ bin/console doctrine:query:dql "select u.roles from App\Entity\User u where u.id='0092b32b-7e54-4a83-a374-275c18f1a335'"

/Users/manuel/Sites/test/vendor/doctrine/common/lib/Doctrine/Common/Util/Debug.php:55:
array (size=1)
  0 =>
    array (size=1)
      'roles' =>
        array (size=2)
          0 => string 'ROLE_USER' (length=9)
          1 => string 'ROLE_ADMIN' (length=10)

Migrating user table to add ROLE_USER

Symfony Maker bundle creates roles entity property as json2, so I’m going to use JSON specific MySQL function to create the migration query:

UPDATE `user` SET `roles` = '["ROLE_USER"]' WHERE JSON_LENGTH(`roles`) = 0;

and verify that the existing users get the new role:

bin/console doctrine:query:dql "select u.roles from App\Entity\User u where u.id='001cf6c4-2d97-4be8-8b62-851ae3265432'"

/Users/manuel/Sites/test/vendor/doctrine/common/lib/Doctrine/Common/Util/Debug.php:55:
array (size=1)
  0 =>
    array (size=1)
      'roles' =>
        array (size=1)
          0 => string 'ROLE_USER' (length=9)

Searching for user role in JSON field

Doctrine 2 doesn’t come with support for MySQL JSON functions, but there is a package called ScientaNL/DoctrineJsonFunctions that add support for them.

Let’s require the package:

$ composer require scienta/doctrine-json-functions

and configure it in config/packages/doctrine.yaml:

doctrine:
    orm:
      dql:
          string_functions:
              JSON_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Mysql\JsonContains

Now I can use JSON_CONTAINS in my DQL, and I can write a better search method:

<?php
    public function findByRoleThatSucksLess(string $role)
    {
        $role = mb_strtoupper($role);

        return $this->createQueryBuilder('u')
            ->andWhere('JSON_CONTAINS(u.roles, :role) = 1')
            ->setParameter('role', '"ROLE_' . $role . '"')
            ->getQuery()
            ->getResult();
    }

What’s missing

The solution described in this post works only if all user roles are listed in the database, this has to be done manually.