What Are Roles and Permissions?
In the web development world, you'll often come across the terms "roles" and "permissions". But what do these mean? A permission is the right to have access to something; for example, a page in a web app. A role is just a collection of permissions.
To give this a bit of context, let's take a simple example of a CMS (content management system). The system could have multiple basic permissions such as:
- Can create blog posts
- Can update blog posts
- Can delete blog posts
- Can create users
- Can update users
- Can delete users
The system could also have roles such as:
- Editor
- Admin
So, we could make the assumption that the 'Editor' role would have the 'can create blog posts', 'can update blog posts' and 'can delete blog posts' permissions. But, they wouldn't have the permissions to create, update or delete users. Whereas an admin would have all of these permissions.
Using roles and permissions like above is a great way of building a system and being able to limit what a user can see and do.
How to Use the Spatie Laravel Permissions Package
There are different ways that you can implement roles and permissions in your Laravel app. You could write the code yourself to handle the entire concept. However, this can sometimes be very time-consuming and for a large majority of cases, using a package is more than sufficient.
In this article, we're going to be using the Laravel Permission package from Spatie.
Installation and Configuration
To get started with using the package, we'll install it using the following command:
1composer require spatie/laravel-permission
Now that we've installed the package, we'll need to publish the database migration and config file:
1php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
We can now run the migrations to create the new tables in our database:
1php artisan migrate
Assuming that we are using the default config values and haven't changed anything in the package's config/permission.php
, we should now have 5 new tables in our database:
-
roles
- This table will hold the names of the roles in your app. -
permissions
- This table will hold the names of the permissions in your app. -
model_has_permissions
- This table will hold data showing which permissions your models (e.g. -User
) have. -
model_has_roles
- This table will hold data showing which roles your models (e.g. -User
) have. -
role_has_permissions
- This table hold data showing the permissions that each role has.
To finish off the basic installation, we can now add the HasRoles
trait to our model:
1use Illuminate\Foundation\Auth\User as Authenticatable;2use Spatie\Permission\Traits\HasRoles;3 4class User extends Authenticatable5{6 use HasRoles;7 8 // ...9}
Creating Roles and Permissions
To get started with adding our roles and permissions to your Laravel application, we'll need to first store them in the database. It's actually really easy to create a new role or permission, because in Spatie's package they're just models: Spatie\Permission\Models\Role
and Spatie\Permission\Models\Permission
.
So this means that if we want to create a new role in our system, we can do something like:
1$role = Role::create(['name' => 'editor']);
And we can create permissions in a similar way:
1$permission = Permission::create(['name' => 'create-blog-posts']);
In most cases, you'll usually define the permissions in your code rather than let your application's users create them. However, you'll likely take a slightly different approach with the roles. You might want to define all the roles yourself in your codebase and not give your users any ability to create new ones. On the other hand, you could create some "seeder" roles yourself (e.g. - Admin) and then provide your users with the functionality to add new ones. This decision mainly comes down to what you're trying to achieve with your system and who the end users are.
If you want to add any default roles and permissions to your application, you can add them using database seeders. You'll probably want to create a seeder specific for this task (maybe called something like RoleAndPermissionSeeder
). So, let's start by making the new seeder using the following command:
1php artisan make:seeder RoleAndPermissionSeeder
This should have created a new /database/seeders/RoleAndPermissionSeeder.php
file. Before we make any changes to this file, we need to remember to update our database/seeders/DatabaseSeeder.php
so that it automatically calls our new seed file whenever we use php artisan db:seed
command:
1namespace Database\Seeders; 2 3use Illuminate\Database\Seeder; 4 5class DatabaseSeeder extends Seeder 6{ 7 public function run() 8 { 9 // ...10 11 $this->call([12 RoleAndPermissionSeeder::class,13 ]);14 15 // ...16 }17}
Now, we can update our new seeder to add some default roles and permissions to our system:
1namespace Database\Seeders; 2 3use Illuminate\Database\Seeder; 4use Spatie\Permission\Models\Permission; 5use Spatie\Permission\Models\Role; 6 7class RoleAndPermissionSeeder extends Seeder 8{ 9 public function run()10 {11 Permission::create(['name' => 'create-users']);12 Permission::create(['name' => 'edit-users']);13 Permission::create(['name' => 'delete-users']);14 15 Permission::create(['name' => 'create-blog-posts']);16 Permission::create(['name' => 'edit-blog-posts']);17 Permission::create(['name' => 'delete-blog-posts']);18 19 $adminRole = Role::create(['name' => 'Admin']);20 $editorRole = Role::create(['name' => 'Editor']);21 22 $adminRole->givePermissionTo([23 'create-users',24 'edit-users',25 'delete-users',26 'create-blog-posts',27 'edit-blog-posts',28 'delete-blog-posts',29 ]);30 31 $editorRole->givePermissionTo([32 'create-blog-posts',33 'edit-blog-posts',34 'delete-blog-posts',35 ]);36 }37}
Assigning Roles and Permissions to Users
Now that we have our roles and permissions in our database and ready to be assigned, we can look at how we can assign them to our users.
First, let's look at how simple it is to assign a new role to a user:
1$user = User::first();2 3$user->assignRole('Admin');
We can also give permissions to that role so that the user will also have that permission:
1$role = Role::findByName('Admin');2 3$role->givePermissionTo('edit-users');
It's possible that you might provide the functionality in your application for permissions to be assigned directly to users as well as (or instead of) assigning them to roles. The code snippet below shows how we can do this:
1$user = User::first();2 3$user->givePermissionTo('edit-users');
As well as being able to assign roles and permissions, you'll need to provide the functionality to remove roles and revoke permissions from users. Here's a quick look at how easy it is to remove a role from a user:
1$user = User::first();2 3$user->removeRole('Admin');
We can also remove permissions from users and roles in a similar way:
1$role = Role::findByName('Admin');2 3$role->revokePermissionTo('edit-users');
1$user = User::first();2 3$user->revokePermissionTo('edit-users');
Restricting Access Based on Permissions
Now that we've got our roles and permissions stored in our database and we know how to assign them to our users, we can take a look at how to add authorisation checks.
The first way that you might want to add authorisation would be through using the \Illuminate\Auth\Middleware\Authorize
middleware. This comes be default in fresh Laravel installations, so, as long as you haven't removed it from your app/Http/Kernel.php
, it should be aliased to can
. So, let's imagine that we have a route that we want to restrict access to unless the authenticated user has the create-users
middleware. We could add the middleware to the individual route:
1Route::get('/users/create', [\App\Http\Controllers\UserController::class, 'create'])->middleware('can:create-users');
You'll likely find that you'll have multiple routes that are related to each other that rely on the same permission. In this case, your routes file might get a bit messy assigning the middleware on a route-by-route basis. So, you can add the authorisation by adding the middleware to a route group instead like so:
1Route::middleware('can:create-users')->group(function () {2 Route::get('/users/create', [\App\Http\Controllers\UserController::class, 'create']);3 Route::post('/users', [\App\Http\Controllers\UserController::class, 'store']);4});
On a side note, it's worth noting that if you prefer to define your middleware in your controller constructors, you can also use the can
middleware there. You might also want to make use of ->authorize()
in your controller methods of using the middleware. Using this method will require you to create policies for your models, but if used properly, this technique can be really useful for keeping your authorisation clean and understandable.
You might find in your application that you sometimes need to manually check whether a user has a specific permission but without denying access completely. We can do this using the ->can()
method on the User
model.
For example, let's imagine that we have a form in our application that allows a user to update their name, email address and password. Now let's say that we want to give users with the 'Editor' role permission to edit users, but not to change another user's password. We'll only allow users to update another user's password if they also have the edit-passwords
permission.
We'll make the assumption in our example below that we are using middleware to only allow users with the edit-users
permission to access this method. Let's take a look at how we could implement this in our controller:
1namespace App\Http\Controllers; 2 3use App\Http\Requests\UpdateUserRequest; 4use App\Models\User; 5use Illuminate\View\View; 6 7class UserController extends Controller 8{ 9 // ...10 11 public function update(UpdateUserRequest $request, User $user): View12 {13 $user->name = $request->name;14 $user->email = $request->email;15 16 if (auth()->user()->can('edit-passwords')) {17 $user->password = $request->password;18 }19 20 $user->save();21 22 return view('users.show')->with([23 'user' => $user,24 ]);25 }26 27 // ...28}
Showing and Hiding Content in Views Based on Permissions
It's likely that you'll want to be able to show and hide parts of your views based on a user's permissions. For example, let's imagine that we have a basic button in our Blade view that we can press to delete a user. Let's also say that the button should only be shown if the user has the delete-users
permission.
To show and hide this button, it's super simple! We can the @can()
Blade directive like so:
1@can('delete-users')2 <a href="/users/1/destroy">Delete</a>3@endcan
If the user has the delete-users
permission, anything inside the @can()
and @endcan
will be displayed. Otherwise, it won't be rendered in the Blade view as HTML.
It's important to remember that hiding buttons, forms and links in your views doesn't provide any server-side authorisation. You'll still need to add authorisation to your backend code (for example, in your controllers or using middleware like explained above) to prevent any malicious users from making any requests to routes that should only be available to users with specific permissions.
How to Add a "Super Admin" Permission
When you create your application, you might want to add a "super admin" role. A perfect example for this could be that you offer a SaaS (software as a service) platform that is multi-tenant. You might want employees at your company to be able to move around the entire application and view different tenant's systems (maybe for debugging and answering support tickets).
Before we add the super admin check, it'd probably be worthwhile taking a quick look at how Spatie's package uses gates in Laravel. Just in case you haven't already come across them, gates are really simple, they're just "closures that determine if a user is authorized to perform a given action".
When you use a piece of code like $user->can('delete-users')
, you're using Laravel's gates.
Before any gates are run to check a permission, we can run code that we define in a before()
method. If any of the before()
closures that are run return true
, the user is allowed access. If a before()
closure returns false, it denies access. If it returns null
, Laravel will proceed and run any outstanding before()
closures and then check the gate itself.
In the \Spatie\Permission\PermissionRegistrar
class in the package, we can see that our permission check is actually added as before()
to run before the gate. If the package determines that the user has the permission (either assigned directly or through a role), it will return true
. Otherwise, it will return null
so that any other before()
closures can be run.
So, we can use this same approach to add a super admin role check to our code. We can add the code to our AuthServiceProvider
:
1use Illuminate\Support\Facades\Gate; 2 3class AuthServiceProvider extends ServiceProvider 4{ 5 public function boot() 6 { 7 // ... 8 9 Gate::before(function ($user, $ability) {10 return $user->hasRole('super-admin') ? true : null;11 });12 13 /// ...14 }15}
Now, whenever we run a line of code like $user->can('delete-users')
, we will be checking whether the user has the delete-users
permission or the super-admin
role. If at least one of the two criteria are satisfied, the user will be allowed access. Otherwise, they will be denied access.
How to Test Permissions and Access
Having an automated test suite that covers your authorisation can be extremely handy! It helps to give you the confidence that you're protecting your routes properly and that only users with the correct permissions can access certain features.
To see how we can write a test for this, we'll start by imagining a simple system that we can write tests for. The tests will only be super basic and can definitely be stricter, but it will hopefully give you an idea of the basic concept of permissions testing.
Let's say that we have a CMS that has 2 default roles: 'Admin' and 'Editor'. We'll also make the assumption that our system doesn't allow assigning permissions directly to a user. Instead, the permissions can only be assigned to roles, and that the user can then be assigned one of them roles.
Let's say that by default, the 'Admin' role has permission to create/update/delete users and create/update/delete blog posts. Let's say that the 'Editor' role only has permission to create/update/delete blog posts.
Now, let's take this basic example route and controller that we could go to for creating a new user:
1Route::get('/users/create', [\App\Http\Controllers\UserController::class, 'create'])->middleware('can:create-users');
1namespace App\Http\Controllers; 2 3use Illuminate\View\View; 4 5class UserController extends Controller 6{ 7 // ... 8 9 public function create(): View10 {11 return view('users.create');12 }13 14 // ...15}
As you can see, we've added authorisation to the route so that only users with the create-users
permission are allowed access.
Now we can write our tests:
1use App\Models\User; 2use Illuminate\Foundation\Testing\RefreshDatabase; 3use Spatie\Permission\Models\Permission; 4use Spatie\Permission\Models\Role; 5use Tests\TestCase; 6 7class UserControllerTest extends TestCase 8{ 9 use RefreshDatabase;10 11 private User $user;12 13 private Role $role;14 15 protected function setUp(): void16 {17 parent::setUp();18 19 $this->user = User::factory()->create();20 21 $this->role = Role::create(['name' => 'custom-role']);22 $this->user->assignRole($this->role);23 24 $this->role->givePermissionTo('create-users');25 }26 27 /** @test */28 public function view_is_returned_if_the_user_has_permission(): void29 {30 $this->actingAs($this->user)31 ->get('/users/create')32 ->assertOk();33 }34 35 /** @test */36 public function access_is_denied_if_the_user_does_not_have_the_right_permission(): void37 {38 $this->role->revokePermissionTo('create-users');39 40 $this->actingAs($this->user)41 ->get('/users/create')42 ->assertForbidden();43 }44}
Bonus Tips
If you're going to be creating the permissions yourself and not letting your users create them, it can be quite useful to store your permission and role names as constants or enums. For example, for defining your permission names, you could have a file like this:
1namespace App\Permissions; 2 3class Permission 4{ 5 public const CAN_CREATE_BLOG_POSTS = 'create-blog-posts'; 6 public const CAN_UPDATE_BLOG_POSTS = 'update-blog-posts'; 7 public const CAN_DELETE_BLOG_POSTS = 'delete-blog-posts'; 8 9 public const CAN_CREATE_USERS = 'create-users';10 public const CAN_UPDATE_USERS = 'update-users';11 public const CAN_DELETE_USERS = 'delete-users';12}
By using a file like this, it can make it much easier to avoid any spelling mistakes that might cause any unexpected bugs. For example, let's imagine that we have a permission called create-blog-posts
and that we have this line of code:
1$user->can('create-blog-post');
If you were reviewing this code in a pull request or writing it yourself, I wouldn't blame you for thinking that it was valid. However, we've missed off the s
from the end of the permission! So, to avoid this problem we could use:
1use App\Permissions\Permission;2 3$user->can(Permission::CAN_CREATE_BLOG_POSTS);
Now we have more confidence that the permission name is right. As an extra bonus, this also makes it super easy if you want to see anywhere that this permission is used, because your IDE (such as PHPStorm) should be able to detect which files it's being used in.
Alternative Packages and Approaches
As well as using Spatie's Laravel Permission package, there are other packages that can be used to add roles and permissions to your application. For example, you can use Bouncer or Laratrust.
You might find that in some of your applications that you might need more bespoke functionality and flexibility than the packages provide. In this case, you might need to write your own roles and permissions implementation. A good starting point for this can be to use Laravel's 'Gates' and 'Policies' as mentioned earlier.
Conclusion
Hopefully, this article should have given you an overview of how you can add permissions to your Laravel applications using the Spatie's Laravel Permission package. It should have also given you an insight into how you can write automated tests in PHPUnit to test that your permissions are set up correctly.