Chapters
-
Course Code
Subscribe to download the code!Compatible PHP versions: >=5.5.9
Subscribe to download the code!Compatible PHP versions: >=5.5.9
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
Dynamic Roles
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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.
Denying access is great... but we still have a User
class that gives every user
the same, hardcoded role: ROLE_USER
:
Show Lines
|
// ... lines 1 - 12 |
class User implements UserInterface | |
{ | |
Show Lines
|
// ... lines 15 - 46 |
public function getRoles() | |
{ | |
return ['ROLE_USER']; | |
} | |
Show Lines
|
// ... lines 51 - 93 |
} |
And maybe that's enough for you. But, if you do need the ability to assign different permissions to different users, then we've gotta go a little further.
Let's say that in our system, we're going to give different users different roles.
How do we do that? Simple! Just create a private $roles
property that's an array.
Give it the @ORM\Column
annotation and set its type to json_array
:
Show Lines
|
// ... lines 1 - 12 |
class User implements UserInterface | |
{ | |
Show Lines
|
// ... lines 15 - 40 |
/** | |
* @ORM\Column(type="json_array") | |
*/ | |
private $roles = []; | |
Show Lines
|
// ... lines 45 - 110 |
} |
Tip
json_array
type is deprecated since Doctrine 2.6, you should use json
instead.
This is really cool because the $roles
property will hold an array of roles,
but when we save, Doctrine will automatically json_encode
that array and store
it in a single field. When we query, it'll json_decode
that back to the array.
What this means is that we can store an array inside a single column, without ever
worrying about the JSON encode stuff.
Returning the Dynamic Roles
In getRoles()
, we can get dynamic. First, set $roles = $this->roles
:
Show Lines
|
// ... lines 1 - 12 |
class User implements UserInterface | |
{ | |
Show Lines
|
// ... lines 15 - 51 |
public function getRoles() | |
{ | |
$roles = $this->roles; | |
Show Lines
|
// ... lines 55 - 61 |
} | |
Show Lines
|
// ... lines 63 - 110 |
} |
Second, there's just one rule that we need to follow about roles: every user must have at least one role. Otherwise, weird stuff happens.
That's no problem - just make sure that everyone at least has ROLE_USER
by saying:
if (!in_array('ROLE_USER', $roles))
, then add that to $roles
. Finally, return $roles
:
Show Lines
|
// ... lines 1 - 12 |
class User implements UserInterface | |
{ | |
Show Lines
|
// ... lines 15 - 51 |
public function getRoles() | |
{ | |
$roles = $this->roles; | |
// give everyone ROLE_USER! | |
if (!in_array('ROLE_USER', $roles)) { | |
$roles[] = 'ROLE_USER'; | |
} | |
return $roles; | |
} | |
Show Lines
|
// ... lines 63 - 110 |
} |
Oh, and don't forget to add a setRoles()
method!
Show Lines
|
// ... lines 1 - 12 |
class User implements UserInterface | |
{ | |
Show Lines
|
// ... lines 15 - 63 |
public function setRoles(array $roles) | |
{ | |
$this->roles = $roles; | |
} | |
Show Lines
|
// ... lines 68 - 110 |
} |
Migration & Fixtures
Generate the migration for the new field:
./bin/console doctrine:migrations:diff
We should double-check that migration, but let's just run it:
./bin/console doctrine:migrations:migrate
Finally, give some roles to our fixture users! For now, we'll give everyone the same
role: ROLE_ADMIN
:
Show Lines
|
// ... lines 1 - 22 |
AppBundle\Entity\User: | |
user_{1..10}: | |
Show Lines
|
// ... lines 25 - 26 |
roles: ['ROLE_ADMIN'] |
Reload the fixtures!
./bin/console doctrine:fixtures:load
Ok, let's go see if we have access! Ah, we got logged out! Don't panic: that's because our user - identified by its id - was just deleted from the database. Just log back in.
So nice - it sends us back to the original URL, we have two roles and we have access. Oh, and in a few minutes - we'll talk about another tool to really make your system flexible: role hierarchy.
So, how do I Set the Roles?
But now, you might be asking me?
How would I actually change the roles of a user?
I'm not sure though... because I can't actually hear you. But if you are asking me this, here's what I would say:
$roles
is just a field on your User
, and so you'll edit it like any other field:
via a form. This will probably live in some "user admin area", and you'll use the
ChoiceType
field to allow the admin to choose the roles for every user:
class EditUserFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder)
{
$builder
->add('roles', ChoiceType::class, [
'multiple' => true,
'expanded' => true, // render check-boxes
'choices' => [
'Admin' => 'ROLE_ADMIN',
'Manager' => 'ROLE_MANAGER',
// ...
],
])
// other fields...
;
}
}
If you have trouble, let me know.
What about Groups?
Oh, and I think I just heard one of you ask me:
What about groups? Can you create something where Users belong to Groups, and those groups have roles?
Totally! And FOSUserBundle
has code for this - so check it out. But really, it's
nothing crazy: Symfony just calls getRoles()
, and you can create that array however
you want: like by looping over a relation:
class User extends UserInterface
{
public function getRoles()
{
$roles = [];
// loop over some ManyToMany relation to a Group entity
foreach ($this->groups as $group) {
$roles = array_merge($roles, $group->getRoles());
}
return $roles;
}
}
Or just giving people roles at random.
64 Comments
Hey gstanto ,
Thanks for sharing it with others!
Cheers!
Hi,
thanks for this very instructive article.
I have a question: the User entity has a setRoles() method so say if a user has several roles and I just want to remove one, I have to get the roles first and then reset them all after removing the one from the roles array(). Is there a more convenient way to achieve this?
Thank you
Yo Steven Carré!
if a user has several roles and I just want to remove one, I have to get the roles first and then reset them all after removing the one from the roles array(). Is there a more convenient way to achieve this?
The way the code is written out-of-the-box, there is not. You have a few options:
1) Say ok! No big deal - I'll just keep my code that does all this work :).
2) Create a new method on User called (for example) removeRole(string $roleName)
and move the logic there for finding the role. Basically the same thing... but the code is more centralized.
3) If you want to, you can actually have an entire UserRole
entity that you could relate to User
and store roles there. Each UserRole
would have an id, so you would know exactly which to delete. This might be overkill... to solve the problem ;).
Cheers!
Hey Ryan,
When loading my fixtures after I added the roles field and migrated then I get this lovely error:
Message: "Warning: Invalid argument supplied for foreach()" ["error" => Symfony\Component\Debug\Exception\ContextErrorExcepti
on { …},"command" => "doctrine:fixtures:load","message" => "Warning: Invalid argument supplied for foreach()"] []. As a result of this nothing gets loaded into my database.
I am using Symfony 3.4
Hey DaveKnifeman!
Ah man! Let's see if we can debug this. First, if you add a -vvv
flag at the end of your command (so bin/console doctrine:fixtures:load -vvv
) you will get a stacktrace on your error, which is usually very helpful to see exactly where the error is coming from!
Second, make sure that when you call setRoles() that you're passing it an array. I think that you might be setting this to a string. And then inside our setRoles() method in User, we are trying to foreach over that.
Let me know if that helps!
Cheers!
Hello ! I
need to sign in with some ROLE, such as ROLE_SELLER_USER, after that, I
need to select a seller from a list and load roles from a Many to Many
table to continue on the next page.
Can you help me ?
Hey Marcelo,
Do you mean you need to have a user that may impersonate other users? e.g. you have a ROLE_SELLER_ADMIN that can log in as any ROLE_SELLER_USER and do some things on their behalf?
Then this Symfony docs might be useful for you: https://symfony.com/doc/cur...
We also say about impersonation here: https://knpuniversity.com/s...
I hope it helps!
Cheers!
Hey Man !
Finally after a little study I was able to solve this problem by creating a Voter with the extractRoles method.
Thanks for help.
Hey Marcelo,
Glad you got it working!
Cheers!
Just a small note: The "json_array" type is deprecated since Doctrine 2.6. You should use "json" instead.
Source: http://docs.doctrine-projec...
EDIT: I actually just tried to use "json" and it does not even work yet, it says the type is not recognized. I don't know :l
Hey Johan,
Nice notice, thanks! Actually, we require only the latest 2.5 version of doctrine ORM due to the next line in composer.json: "doctrine/orm": "^2.5"
. So Composer won't use 2.6 for this project even when it'll be available.
But I think we could add a note about it, I'll add it.
Cheers!
UPD: Note added in https://github.com/knpuniversity/symfony/commit/cfae786aa1f6535885fa8d5d09e578870082c672
Ryan, what would be the different doing the @Route(/admin) at the top of the controller vs using the firewall? And if in this case there are no different, what is the advantage of using on or other.
Thansk
Hey Richard,
That's different things. The "@Route" annotation at the top of any controller class is just a prefix for <em>all</em> routes, which this controller has. It means, that if you have:
/**
* @Route("/admin")
*/
class GenusController
{
/**
* @Route("/genus")
*/
public function indexAction()
{
// code here...
}
}
so the indexAction()
URL will be the "/admin/genus" (i.e. with "/admin" prefix) but not just the "/genus". That's it!
Firewall in turn allows you to configure <em>who</em> has access to a page.
Cheers!
Hi Victor, sorry my bad, What I want to say, was, what is the different about using @Security(/admin) vs using in the firewall ^/admin.
Hey Richard!
Ah, that makes more sense :). Or, more specifically - you mean @Security("ROLE_ADMIN").
It's more or less perfectly equivalent. The ^/admin in the firewall causes some code to run that compares the URL and if matches, checks for ROLE_ADMIN. If you use @Security, it does the same thing - except instead of checking if the URL matches, it checks to see if that controller class is being executed on this request.
So it comes down to a matter or preference: do you like protecting by URL better? Or do you like to secure specific controller classes. It's nice to have both options :).
Cheers!
Thanks guys, is clear now :)
Regards,
Hmm. I was used to roles and permissions structure. I mean, used to have a roles table, permissions table, pivot tables between them, user could have roles but also a independent permissions and so on. I was using https://github.com/romanbic... with Laravel.
How this relates to Symfony? Do groups act as roles and roles become permissions in Symfony world? If a role represents a set of permissions, hard coded in my application (according to: http://stackoverflow.com/qu... ) how am I supposed to associate permissions with roles?
Hi, I would like to make the user select only one role. But with the choiceType with multiple false, it gives a string and then a nice error appears, as expected. Do you have a hint? Thx...
By the way as you can guess I'm a newbie
Hey Julien,
There's a lot of ways to do it. If you're good at JavaScript, you could write a simple JS script which will allow you to select only one checkbox, i.e. reset all checkboxes except the latest. But probably there's another better solution: since UserInterface enforce you to have setRoles()/getRoles(), you can add a new "virtual" ChoiceType form field which name is "role", make it "mapped => false" and add User::setRole() method which will get a role string value, add it to an empty array and set this array to the $this->roles property overwriting the current role. The 3rd is more complex I think, but should work well too - choiceType with multiple false and create a Data Transformer for this field, which will convert single role string to an array and back: array with a single role to the single role string.
Cheers!
Hi Victor, tanks for the reply. Until I get your answer I used jquery. But I would like to use the virtual choice type method. But I can it make it... :-(
The roles column in the database is empty.
Where do I have to create the method? In the User entity or in the form?
Hey Julien,
Let me a bit improve and probably simplify my original idea: if you want to be able to set only one single role for your users, then you don't need User::$roles property at all, you just need the User::$role field. So your User entity may looks like:
User
{
// other properties...
/**
* @ORM\Column(type="string")
*/
private $role;
public function getRole()
{
return $this->role;
}
public function setRole($role)
{
$this->role = $role;
}
// You have to keep this method due to the UserInterface
public function getRoles()
{
return [$this->role];
}
public function setRoles(array $roles)
{
throw new \Exception('You probably never need to call it, but UserInterface enforce you to have this method')
}
// other methods...
}
How does it looks like? I.e. you replace User::$roles property with User::$role one. And then in your form just add a normal field for this $role property, so you event don't need "mapped => false" now.
P.S. Sorry for misleading you in my first reply.
Cheers!
Thank you so much. I realized that the pb was not (only) a lack of understanding of symphony, but OOP in general... I have to work more and more. By the way your courses are so great... I spent hours and hours on reading, searching and following different courses... But yours are so well executed and make me feel less dumb :-)!!!
Hey Julien,
Thank you for the kind words! Btw, we have a separate track for OOP if you're interested in it:
https://knpuniversity.com/t...
Cheers!
He Ryan/Viktor,
A question regarding fixtures. ./bin/console doctrine:fixtures:load gives a fatal once i already have content, because of the foreign key constraints. This happens when it wants to truncate each table.
Is there a way to either:
1) set the order in which tables are truncated?
2) or to execute a query before it runs the purge()-command?
I know i can delete and recreate the entire database, but i find that a pretty lame workaround.
Kind Regards!
Hey Daan!
This "shouldn't" happen, because the command calculates the correct order that the tables should be truncated. But, it can happen if you have some circular relationships (e.g. two tables - or maybe a bigger loop - that each have foreign keys that point back to each other). One way to fix that - if it's appropriate for your app - is to add some @JoinColumn(onDelete="CASCADE")
(or SET NULL) functionality, so that when items in one of those relationships is deleted, it cascades in the database.
But, if that's not really appropriate for your app (i.e. CASCADE would be potentially dangerous for that relationship in production), then you will need a workaround, as you suggested. There are no hook points that I know of, so I would recommend just making a simple shell script that runs all the commands for you (database:drop, database:create, migrations:migrate, fixtures:load).
Cheers!
Aaah to bad. Thanks for the reply. I'll make a bash script then :)
Hi Ryan,
In my old blog system implemented with plain php, I had 3 tables for posts, comments and users. In post/comment-table theres a authorId column which points to an user. and in the user-table, infos like password and roles are stored. Now I want to rebuild this with symfony. So I would have post/comment entities, in post there will be a private $comment with OneToMany relation to the comment-entity and so on...
my question ist now about user, is it possible to make an "class User implement UserInterface", so I have access to all the authentication stuff, and at the same time, have ManyToOne-relation in post/comment entities point to the user? something like
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
* @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
*/
private $user;
Btw. I recall in the doctrine relationship tutorial, you said something about theres only ManyToOne or ManyToMany relationship. So I wouln't need to add extra properties like $comments or $posts in the user class?
Hey Yang,
Let me help you. Yes, it's definitely possible! The UserInterface just enforce you to have authentication-related fields on an entity, that's it. So you can add whatever you need fields to the User entity besides UserInterface required fields, even some relation properties like ManyToMany, etc.
Yes, you're right! You wouldn't need to add these extra properties, but you could if it'll be useful for you, i.e. to build some complex join queries or just for convenience to get a collection of related entities.
Cheers!
Hey! :) I created EditUserForm, finally. I had a little problem with roles: sometimes roles were saved as json array ["ROLE_USER","ROLE_ADMIN"], sometimes as json object {"1":"ROLE_USER"}.
I solved it with next line, is there some better way? Thanks!<br />$user->setRoles(array_values($form->get("roles")->getData()));<br />
Hey Danijela
I'm not sure why that's happening, but can you double check that you chose "json_array" as the type of your $roles property?
Another thing you can do is passing the User instance as a second parameter to your FormType (when creating it), so it automatically updates the properties.
// UserController.php
public function editAction(Request $request, User $user) {
...
$editForm = $this->createForm(UserEditType::class, $user)
...
}
Cheers!
Hey MolloKhan
I choose json_array type, and also set second parameter for createForm(..). Maybe I missed something else, I will check it all again.
I found the similar issue here, maybe it is smilar issue, i dont know.. :)
Thank you for response :)
Or maybe is something related to your environment. Which OS, and database driver are you using?
I had the same problem and ended up swapping json_array for simple_array which saved everything as a comma delimited list.
Oh, that's interesting. I've never have that problem - Maybe it's because of the MySql version. On which version are you running?
Just a quick one, if I add different users which have different accounts with their details and their favorites etc.. How would you protect each new users account. I would say dynamically but I am not sure how, maybe with their ID I suppose?
Hi James!
I'm not 100% sure I'm going to answer what you're actually asking, but let me give it a shot - then you can tell me that I didn't fully understand your question if I miss :).
Giving each user a different role is cool, but it's still a "global" property - it helps to answer questions like "should the user have access to the blog admin area in general". But, roles can't be used for security that's based on an object - e.g. perhaps a user should have access to only the blog posts that they author. So, they will have access to edit some blog posts, but not others. Is this kind of what you're asking?
In short, there are 2 ways to solve this:
1) Manually (I don't often do this, but you'll see how simple it is). For example, suppose there is some url like /users/{id}, but I should only be able to see my page. Then, you might do this in the controller:
`<em>
public function showAction($id)
{
$user = // query for the User
if ($user != $this->getUser)) {
throw $this->createAccessDeniedException('this is not your account!');
}
}
</em>`
So, you can simply put whatever security logic you need in your controller and then deny access like this.
2) A better solution is to use voters: https://knpuniversity.com/screencast/new-in-symfony3/voter. These are very similar to doing what I just did above, but you centralize your security logic so that you can more easily re-use it.
Does this help? Or did I totally answer the wrong question? :)
Cheers!
Voters is it ! Thanks again! But if I still want admin to have access, how do I add the admin to the if statement? And is there some king of super admin for developers like you talked about in one of you tuto?
Thanks
Hey James,
In if statement you could check whether the current user has a role ROLE_ADMIN <em>or</em> the current user is owner (like in Ryan's example) - then allow access:
public function showAction($id)
{
$user = // query for the User
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if ($user != $this->getUser()) {
throw $this->createAccessDeniedException('this is not your account!');
}
}
Cheers!
Hi everyone!
I am following your course but I use Postgres instead of MySql. I encountered a problem with the "roles" column.
Migration generated by console creates json column (with NOT NULL), as expected. Running the migrate command generates an error that column roles cannot be null
I tried to add columnDefinition='default=\'[\']::JSON' in "@ORM\Column" annotation but that generates "ALTER..." SQL without column type ((...) ADD roles default '[]'::JSON).
I ended up removing the columnDefinition part (so I had User.php like the one in this course) and modifying the migration file: $this->addSql('ALTER TABLE "user" ADD roles JSON NOT NULL DEFAULT \'[]\'::JSON');
I hope that someone can benefit from that, should one encounter such problem or maybe you have some other solution for that problem.
Cheers!
Hey Karol Goraus
Thanks for sharing your problem and solution :)
But instead of setting a default value at the DataBase level, you can do it in your entity code
// User.php
class User {
....
// Set default to an empty array
private $roles = [];
....
}
I believe it should give you the same result
Cheers!
Hi Ryan/Victor,
I get an empty array in roles database column! help plz
Hey Mounir,
Check your setter/getter methods - maybe you just made a logic typo there. Also, try to debug roles before doing persist() and flush() with:
dump($user->getRoles());
die;
$em->persist($user);
$em->flush();
Or if you set roles only in data fixtures, try to dump the result array which your getter returns:
public function getRoles()
{
$roles = [];
// loop over some ManyToMany relation to a Group entity
foreach ($this->groups as $group) {
$roles = array_merge($roles, $group->getRoles());
}
dump($roles); die;
return $roles;
}
Btw, do you use MySQL database?
Cheers!
Sorry, i have just seen your reply.
Yes i do use MySQL dababase.
My getRoles() look like this:
public function getRoles()
{
$roles = $this->roles;
// give everyone ROLE_USER!
if ( !in_array('ROLE_USER', $roles) ) {
$roles[] = 'ROLE_USER';
}
return $roles;
}
I have no groups property in User class.
when i dump $roles in getRoles() i get ['ROLE_USER']
and when i dump $user->getRoles() in registerAction(), before persist method, i get ['ROLE_USER'].
But dumping $user in registerAction(), before persist method returns roles with empty array.
Thanks Victor!
Hey Mounir,
Great! This's exactly the correct behavior. So ROLE_USER in this case is dynamicly added to all your users at runtime, even if they don't have any roles. It allows you do not worry about assigning default ROLE_USER to all your users. But if you add a different role to a user - it will be stored into the DB! So check if it works correctly by adding a new ROLE_ADMIN for the admin user, then persist() and flush() - you should see the only ROLE_ADMIN in database as we expected.
Cheers!
Thanks guys, is clear now :)
Hello KNP,
I've got a weird thing with the $roles json_array. I save the user to the database with this code:
$user = new User();
$user->setEmail('RyanIsAwesome@gmail.com');
$user->setPlainPassword('SuperCoolPassword');
$user->setRoles('ROLE_ADMIN');
$em = $this->getDoctrine()->getManager();
$em->persist($user);
$em->flush();
All goes well, and the user is saved into the database.
Now when I login, It's not returning the $roles as an array, so it breaks on this line
if(!in_array('ROLE_USER', $roles)) {
Now I fixed it with this:
$roles = (array)$this->roles;
But why isn't it an array?
Greetz, Dennis
Hey Dennis,
I suppose it's because in your code you're passing role as a string to setRoles() method, i.e. $user->setRoles('ROLE_ADMIN')
. Check your setRoles() setter, in our project it has "setRoles(array $roles)" signature, which means your should pass an array of roles like: $user->setRoles(['ROLE_ADMIN'])
. If you still have some problems with it - please, show your setRoles() method declaration.
Cheers!
Hi victor,
I did not have that indeed.
I passed a string to the setRoles method. Now I've changed that, and adjusted the setRoles function to setRoles(array $roles)!
Thanks!
Hi, I am thinking about best practices for following scenario: I have two roles ROLE_USER, ROLE_ADMIN. Also, I have object (i.e. Article) which can be in two states. draft, assigned. And also can be assigned to user. A want to list all articles but user with ROLE_USER can see only articles in draft state and assigned articles. User with role ROLE_ADMIN can see all articles. And question is. Is better practice to create controller with one action which handles each roles (based on role it picks proper repository method) or I should use two dedicated actions .... separated for each ROLE?
Thank you for your response.
"Houston: no signs of life"
Start the conversation!
What PHP libraries does this tutorial use?
// composer.json
{
"require": {
"php": ">=5.5.9",
"symfony/symfony": "3.1.*", // v3.1.4
"doctrine/orm": "^2.5", // v2.7.2
"doctrine/doctrine-bundle": "^1.6", // 1.6.4
"doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
"symfony/swiftmailer-bundle": "^2.3", // v2.3.11
"symfony/monolog-bundle": "^2.8", // 2.11.1
"symfony/polyfill-apcu": "^1.0", // v1.2.0
"sensio/distribution-bundle": "^5.0", // v5.0.22
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.16
"incenteev/composer-parameter-handler": "^2.0", // v2.1.2
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"knplabs/knp-markdown-bundle": "^1.4", // 1.4.2
"doctrine/doctrine-migrations-bundle": "^1.1" // 1.1.1
},
"require-dev": {
"sensio/generator-bundle": "^3.0", // v3.0.7
"symfony/phpunit-bridge": "^3.0", // v3.1.3
"nelmio/alice": "^2.1", // 2.1.4
"doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
}
}
What I had to do to make this work on Symfony 4:
When running migration initially I received this error:
<blockquote>SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'JSON NOT NULL COMMENT '(DC2Type:json_array)'' at line 1
</blockquote>
I tried to manually tweak the migration as Karol suggested below, but did not work for me.
I checked Mysql Version (As installed with setting from Ansible tutorial - recommended, btw)
Ver 14.14 Distrib 5.5.59, for debian-linux-gnu (x86_64) using readline 6.3
Then needed to tweak doctrine.yaml
Then it worked fine. Maybe that will save someone the hour I spent figuring it out.