Lucas Fiege

Create an Admin middleware for Laravel with spatie/laravel-permission

Photo by CMDR Shane on Unsplash

Photo by CMDR Shane on Unsplash

Although there are many articles about this topic, I decided to document this in a post for my future self and to share with all of you the approach I usually use to separate an application depending on specific roles.

Middleware provides a convenient mechanism for filtering HTTP requests entering your application. For example, Laravel includes a middleware that verifies the user of your application is authenticated. If the user is not authenticated, the middleware will redirect the user to the login screen. However, if the user is authenticated, the middleware will allow the request to proceed further into the application.

Additional middleware can be written to perform a variety of tasks besides authentication. A CORS middleware might be responsible for adding the proper headers to all responses leaving your application. A logging middleware might log all incoming requests to your application.

There are several middlewares included in the Laravel framework, including middleware for authentication and CSRF protection. All of these middlewares are located in the app/Http/Middleware directory.

Creating a custom Admin middleware in Laravel

laravel-permission is a great package developed by the Spatie team that allows you to manage user permissions and roles in a database.

For this example, we are going to install the package and create custom middleware to group our administration routes into a new single route file under the same access control logic for all admin routes.

I will simplify the example in this post to use only two roles: Admin and User (without assigning specific permissions).

Setup of spatie/laravel-permission package

First of all, you must fill your .env file with a new database configuration.

This package can be used in Laravel 5.4 or higher. If you are using an older version of Laravel, take a look at the v1 branch of this package.

You can install the package via composer:

composer require spatie/laravel-permission

The service provider will automatically get registered. Or you may manually add the service provider in your config/app.php file:

'providers' => [
    // ...
    Spatie\Permission\PermissionServiceProvider::class,
];

You can publish the migration with:

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" --tag="migrations"

After the migration has been published you can create the role- and permission-tables by running the migrations:

php artisan migrate

Optionally you can publish the config file with:

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" --tag="config"

add the Spatie\Permission\Traits\HasRoles trait to your User model:

use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
    use HasRoles;
    // ...
}

Using Laravel Authentication system

Make sure of the Laravel authentication feature is present, if not, you can setup this with the artisan command:

php artisan make:auth

For laravel 6+ use Laravel UI

Creating some Users

Now add some users to your application, optionally you can use a Seeder to achieve this. For example, create a new RolesAndPermissionsSeeder by running:

php artisan make:seeder RolesAndPermissionsSeeder

Now paste the following code into the new seeder

public function run()
{
    // Reset cached roles and permissions
    app()['cache']->forget('spatie.permission.cache');


    Role::create(['name' => 'user']);
    /** @var \App\User $user */
    $user = factory(\App\User::class)->create();

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

    /** @var \App\User $user */
    $admin = factory(\App\User::class)->create([
        'name' => 'John Doe',
        'email' => 'john@example.com',
    ]);

    $admin->assignRole('admin');
}

Don’t forget to import the Role class with use Spatie\Permission\Models\Role. Now we have two different users with different roles.

Note: How we have relied on the UserFactory class, the default password for both is password (in Laravel 5.8, for earlier versions the default password is secret)

You can now seed your database with this command:

php artisan db:seed --class=RolesAndPermissionsSeeder

Creating the new middleware with the custom route file

We are ready to create a new middleware to separate admin routes from user routes. It should be noted that an administrator can access the routes of a normal user, but not in an inverse way.

Step 0: Add a test To achieve this quickly, I will rely on the use of automated tests using the integrated PHPUnit on Laravel:

php artisan make:test RolesAccessTest

And add the following tests:

<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;

class RolesAccessTest extends TestCase
{
    /** @test */
    public function user_must_login_to_access_to_admin_dashboard()
    {
        $this->get(route('admin.dashboard'))
             ->assertRedirect('login');
    }

    /** @test */
    public function admin_can_access_to_admin_dashboard()
    {
        //Having
        $adminUser = factory(User::class)->create();

        $adminUser->assignRole('admin');

        $this->actingAs($adminUser);

        //When
        $response = $this->get(route('admin.dashboard'));

        //Then
        $response->assertOk();
    }

    /** @test */
    public function users_cannot_access_to_admin_dashboard()
    {
        //Having
        $user = factory(User::class)->create();

        $user->assignRole('user');

        $this->actingAs($user);

        //When
        $response = $this->get(route('admin.dashboard'));

        //Then
        $response->assertForbidden();
    }

    /** @test */
    public function user_can_access_to_home()
    {
        //Having
        $user = factory(User::class)->create();

        $user->assignRole('user');

        $this->actingAs($user);

        //When
        $response = $this->get(route('home'));

        //Then
        $response->assertOk();
    }

    /** @test */
    public function admin_can_access_to_home()
    {
        //Having
        $adminUser = factory(User::class)->create();

        $adminUser->assignRole('admin');

        $this->actingAs($adminUser);

        //When
        $response = $this->get(route('home'));

        //Then
        $response->assertOk();
    }
}

Obviously, these assertions could be improved by adding others to check views and/or content that should be shown in those sections, but for this post, these tests are sufficient.

If you run this test now, you will get the following errors:

./vendor/bin/phpunit --filter RolesAccessTest
PHPUnit 7.4.3 by Sebastian Bergmann and contributors.
EEE.. 5 / 5 (100%)
Time: 219 ms, Memory: 18.00MB

There were 3 errors:
1) Tests\Feature\RolesAccessTest::user_must_login_to_access_to_admin_dashboard
InvalidArgumentException: Route [admin.dashboard] not defined.
...
ERRORS!
Tests: 5, Assertions: 2, Errors: 3.

Let’s start writing the code so that this test passes:

Create a new temporary route into the routes/web.php file, your file will look like this:

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::get('/admin/dashboard', function(){
    return 'Wellcome Admin!';
})->name('admin.dashboard');

Now, re-run the test:

./vendor/bin/phpunit --filter RolesAccessTest
PHPUnit 7.4.3 by Sebastian Bergmann and contributors.
F.F.. 5 / 5 (100%)
Time: 228 ms, Memory: 18.00MB

There were 2 failures:
1) Tests\Feature\RolesAccessTest::user_must_login_to_access_to_admin_dashboard
Response status code [200] is not a redirect status code.
Failed asserting that false is true.
...
2) Tests\Feature\RolesAccessTest::users_cannot_access_to_admin_dashboard
Response status code [200] is not forbidden.
Failed asserting that false is true.
...
FAILURES!
Tests: 5, Assertions: 5, Failures: 2.

At this point, we have 2 failures only, the admin route is public and is not restricted to the Admin role only, let’s fix it in a few steps.

Step 1: Create a map for the new Admin routes Go to the app\Providers\RouteServiceProvider. In the map method add a new function to map the Admin routes:

public function map()
{
    $this->mapApiRoutes();

    $this->mapWebRoutes();

    $this->mapAdminRoutes();

    //
}

and now implement the new mapAdminRoutes method inside the provider:

protected function mapAdminRoutes()
{
    Route::middleware('admin')
         ->namespace($this->namespace)
         ->group(base_path('routes/admin.php'));
}

Note: optionally, I recommend separating also the namespace of the controllers that will be used by the admin routes and that also, the users must have the admin role

...
->namespace($this->namespace . '\\Admin')
...

Step 2: Create new admin.php file into routes folder

Add a new file into the routes folder called admin.php and move the route for admins inside of this new file:

<?php

Route::get('/admin/dashboard', function(){
    return 'Welcome Admin!';
})->name('admin.dashboard');

Step 3: Create the Admin middleware Open the app\Http\Kernel class and find the $routeMiddleware attribute and add two new middleware that belong to spatie/laravel-permission package :

protected $routeMiddleware = [
    ...
    'role' => \Spatie\Permission\Middlewares\RoleMiddleware::class,
    'permission' => \Spatie\Permission\Middlewares\PermissionMiddleware::class,
];

And in the $middlewareGroups attribute add this new admin middleware group:

protected $middlewareGroups = [
    'web' => [
        ....
    ],

    'admin' => [
        'web',
        'auth',
        'role:admin'
    ],

    'api' => [
        ...
    ],
];

This group specifies that the middleware will make use of the web and auth middleware and that also, the users must have the admin role. This middleware group was indicated in the mapAdminRoutes method into our RouteServiceProvider.

Final Step: Now you can re-run the tests and check the results

./vendor/bin/phpunit — filter RolesAccessTest
PHPUnit 7.4.3 by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 215 ms, Memory: 18.00MB

OK (5 tests, 6 assertions)

Now we have the new middleware working correctly to restrict access to administration routes only to the corresponding users. You can also perform a manual check with the created users in the Seeder

Admin user successfully logged in into the admin routes

And if you try to enter in the admin routes with the normal user you will get a Forbidden response:

Normal user cannot enter to admin routes

In Closing

With this approach, you will get a clear separation between the Admin routes and the normal User routes. We only needed to:

  • Install and configure spatie/laravel-permission
  • Create and assign desired Roles to Users
  • Create a new admin middleware group in the app\Http\Kernel class
  • Create a new mapAdminRoutes into the RouteServiceProvider to map a new routes/admin.php file and assign it to the new admin middleware group

And some advantages of this approach are the separations of concerns for different app components:

  • Separated routes files
  • Separated Controllers with a custom namespace

And also you can use this approach to separate resources files as assets or layouts and view files.

As we have guided our development through the use of automated tests (TDD), we have not needed to manually test with each user of our application during the development, which is also another great advantage.

If you liked this post I invite you to support inviting me a coffee

Invitame un café en cafecito.app


almost 4 years ago

Lucas Fiege