DEV Community

Honeybadger Staff for Honeybadger

Posted on • Originally published at honeybadger.io

A Complete Guide To Managing User Permissions In Laravel Apps

This article was originally written by Ashley Allen on the Honeybadger Developer Blog.

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, such as 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 content management system (CMS). The system could have multiple basic permissions, including the following:

  • 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 the following:

  • Editor
  • Admin

So, we could assume 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 those listed above is a great way to build a system with the ability to limit what a user can see and do.

How to Use the Spatie Laravel Permissions Package

There are different ways to 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 in most cases, using a package is more than sufficient.

In this article, we'll 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:

composer require spatie/laravel-permission
Enter fullscreen mode Exit fullscreen mode

Now that we've installed the package, we'll need to publish the database migration and config file:

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
Enter fullscreen mode Exit fullscreen mode

We can now run the migrations to create the new tables in our database:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

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 five new tables in our database:

  1. roles - This table will hold the names of the roles in your app.
  2. permissions - This table will hold the names of the permissions in your app.
  3. model_has_permissions - This table will hold data showing which permissions your models (e.g., User) have.
  4. model_has_roles - This table will hold data showing which roles your models (e.g., User) have.
  5. role_has_permissions - This table will hold data showing the permissions that each role has.

To finish the basic installation, we can now add the HasRoles trait to our model:

use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasRoles;

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Creating Roles and Permissions

To get started with adding our roles and permissions to our Laravel application, we'll need to first store them in the database. It's simple 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 the following:

$role = Role::create(['name' => 'editor']);
Enter fullscreen mode Exit fullscreen mode

We can create permissions in a similar way:

$permission = Permission::create(['name' => 'create-blog-posts']);
Enter fullscreen mode Exit fullscreen mode

In most cases, you'll 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 specifically for this task (maybe called something like RoleAndPermissionSeeder). So, let's start by making the new seeder using the following command:

php artisan make:seeder RoleAndPermissionSeeder
Enter fullscreen mode Exit fullscreen mode

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 the php artisan db:seed command:

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        // ...

        $this->call([
            RoleAndPermissionSeeder::class,
        ]);

        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we can update our new seeder to add some default roles and permissions to our system:

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RoleAndPermissionSeeder extends Seeder
{
    public function run()
    {
        Permission::create(['name' => 'create-users']);
        Permission::create(['name' => 'edit-users']);
        Permission::create(['name' => 'delete-users']);

        Permission::create(['name' => 'create-blog-posts']);
        Permission::create(['name' => 'edit-blog-posts']);
        Permission::create(['name' => 'delete-blog-posts']);

        $adminRole = Role::create(['name' => 'Admin']);
        $editorRole = Role::create(['name' => 'Editor']);

        $adminRole->givePermissionTo([
            'create-users',
            'edit-users',
            'delete-users',
            'create-blog-posts',
            'edit-blog-posts',
            'delete-blog-posts',
        ]);

        $editorRole->givePermissionTo([
            'create-blog-posts',
            'edit-blog-posts',
            'delete-blog-posts',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

$user = User::first();

$user->assignRole('Admin');
Enter fullscreen mode Exit fullscreen mode

We can also give permissions to that role so that the user will also have that permission:

$role = Role::findByName('Admin');

$role->givePermissionTo('edit-users');
Enter fullscreen mode Exit fullscreen mode

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) assign them to roles. The code snippet below shows how we can do this:

$user = User::first();

$user->givePermissionTo('edit-users');
Enter fullscreen mode Exit fullscreen mode

Along with 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:

$user = User::first();

$user->removeRole('Admin');
Enter fullscreen mode Exit fullscreen mode

We can also remove permissions from users and roles in a similar way:

$role = Role::findByName('Admin');

$role->revokePermissionTo('edit-users');
Enter fullscreen mode Exit fullscreen mode
$user = User::first();

$user->revokePermissionTo('edit-users');
Enter fullscreen mode Exit fullscreen mode

Restricting Access Based on Permissions

Now that we've got our roles and permissions stored in our database and know how to assign them to our users, we can take a look at how to add authorization checks.

The first way that you might want to add authorization would be through using \Illuminate\Auth\Middleware\Authorize middleware. This comes 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:

Route::get(
    '/users/create',
    [\App\Http\Controllers\UserController::class, 'create']
)->middleware('can:create-users');
Enter fullscreen mode Exit fullscreen mode

You'll likely find that you have multiple routes that are related to each other and rely on the same permission. In this case, your routes file might get a bit messy due to assigning the middleware on a route-by-route basis. So, you can add the authorization by adding the middleware to a route group instead:

Route::middleware('create-users')->group(function () {
    Route::get(
        '/users/create',
        [\App\Http\Controllers\UserController::class, 'create']
    );

    Route::post(
        '/users',
        [\App\Http\Controllers\UserController::class, 'store']
    );
});
Enter fullscreen mode Exit fullscreen mode

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 authorization 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 assume 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:

namespace App\Http\Controllers;

use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
use Illuminate\View\View;

class UserController extends Controller
{
    // ...

    public function update(UpdateUserRequest $request, User $user): View
    {
        $user->name = $request->name;
        $user->email = $request->email;

        if (auth()->user()->can('edit-passwords')) {
            $user->password = $request->password;
        }

        $user->save();

        return view('users.show')->with([
            'user' => $user,
        ]);
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 use the@can() Blade directive:

@can('delete-users')
    <a href="/users/1/destroy">Delete</a>
@endcan
Enter fullscreen mode Exit fullscreen mode

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 authorization. You'll still need to add authorization to your backend code (e.g., in your controllers or using middleware as explained above) to prevent 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 an application, you might want to add a "super admin" role. A perfect example for this could be that you offer a software as a service (SaaS) platform that is multi-tenant. You might want employees of 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 to take a quick look at how Spatie's package uses gates in Laravel. 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 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:

use Illuminate\Support\Facades\Gate;

class AuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
        // ...

        Gate::before(function ($user, $ability) {
            return $user->hasRole('super-admin') ? true : null;
        });

        /// ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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 is satisfied, the user will be allowed access. Otherwise, the user will be denied access.

How to Test Permissions and Access

Having an automated test suite that covers your authorization 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 two default roles: 'Admin' and 'Editor'. We'll also assume that our system doesn't allow assigning permissions directly to a user. Instead, the permissions can only be assigned to roles, and 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:

Route::get(
    '/users/create',
    [\App\Http\Controllers\UserController::class, 'create']
)->middleware('can:create-users');
Enter fullscreen mode Exit fullscreen mode
namespace App\Http\Controllers;

use Illuminate\View\View;

class UserController extends Controller
{
    // ...

    public function create(): View
    {
        return view('users.create');
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we've added authorization to the route so that only users with the create-users permission are allowed access.

Now, we can write our tests:

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class UserControllerTest extends TestCase
{
    use RefreshDatabase;

    private User $user;

    private Role $role;

    protected function setUp()
    {
        parent::setUp();

        $this->user = User::factory()->create();

        $this->role = Role::create(['name' => 'custom-role']);
        $this->user->assignRole($this->role);

        $this->role->givePermissionTo('create-users');
    }

    /** @test */
    public function view_is_returned_if_the_user_has_permission()
    {
        $this->actingAs($this->user)
            ->get('/users/create')
            ->assertOk();
    }

    /** @test */
    public function access_is_denied_if_the_user_does_not_have_permission()
    {
        $this->role->revokePermissionTo('create-users');

        $this->actingAs($this->user)
            ->get('/users/create')
            ->assertForbidden();
    }
}
Enter fullscreen mode Exit fullscreen mode

Bonus Tips

If you’ll 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:

namespace App\Permissions;

class Permission
{
    public const CAN_CREATE_BLOG_POSTS = 'create-blog-posts';
    public const CAN_UPDATE_BLOG_POSTS = 'update-blog-posts';
    public const CAN_DELETE_BLOG_POSTS = 'delete-blog-posts';

    public const CAN_CREATE_USERS = 'create-users';
    public const CAN_UPDATE_USERS = 'update-users';
    public const CAN_DELETE_USERS = 'delete-users';
}
Enter fullscreen mode Exit fullscreen mode

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:

$user->can('create-blog-post');
Enter fullscreen mode Exit fullscreen mode

If you were reviewing this code in a pull request or writing it yourself, I wouldn't blame you for thinking that it is valid. However, we've omitted the s from the end of the permission! So, to avoid this problem, we could use the following:

use App\Permissions\Permission;

$user->can(Permission::CAN_CREATE_BLOG_POSTS);
Enter fullscreen mode Exit fullscreen mode

Now, we have more confidence that the permission name is correct. As an extra bonus, this also makes it super easy if you want to see anywhere that this permission is used, because your IDE (e.g., 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 could use Bouncer or Laratrust.

You might find that you need more bespoke functionality and flexibility in some of your applications than the packages provide. In this case, you might need to write your own roles and permissions implementation. A good starting point for this would be to use Laravel's 'Gates' and 'Policies', as mentioned earlier.

Conclusion

Hopefully, this article has given you an overview of how to add permissions to your Laravel applications using Spatie's Laravel Permission package. It should have also given you insight into how to can write automated tests in PHPUnit to test that your permissions are set up correctly.

Top comments (0)