Create an Admin middleware for Laravel with spatie/laravel-permission
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' => '[email protected]',
]);
$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 ispassword
(in Laravel 5.8, for earlier versions the default password issecret
)
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
And if you try to enter in the admin routes with the normal user you will get a Forbidden response:
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 theapp\Http\Kernel class
- Create a new
mapAdminRoutes
into theRouteServiceProvider
to map a newroutes/admin.php
file and assign it to the newadmin
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