In one of the previous lessons, we protected the products page from unauthenticated users. Now, in this lesson, let's talk about authorization access by roles, whether the user is an administrator or not.
First, we create a migration to add a new field, is_admin
to the users
table.
php artisan make:migration "add is admin to users table"
database/migrations/xxx_add_is_admin_to_users_table.php:
public function up(): void{ Schema::table('users', function (Blueprint $table) { $table->boolean('is_admin')->default(false); });}
app/Models/User.php:
class User extends Authenticatable{ // ... protected $fillable = [ 'name', 'email', 'password', 'is_admin', ]; // ...}
And then, in the products list, we restrict the create button.
resources/views/products/index.blade.php:
// ...<div class="min-w-full align-middle"> @if (auth()->user()->is_admin) <a href="{{ route('products.create') }}" class="..."> Add new product </a> @endif <table class="min-w-full divide-y divide-gray-200 border">// ...
This is a protection on the front-end. But, of course, we must protect it on the back-end, too. For this, let's create a Middleware.
php artisan make:middleware IsAdminMiddleware
app/Http/Middleware/IsAdminMiddleware.php:
class IsAdminMiddleware{ public function handle(Request $request, Closure $next): Response { if (! auth()->user()->is_admin) { abort(403); } return $next($request); }}
We need to register the Middleware with an alias. Here's the Laravel 11+ syntax for it.
bootstrap/app.php:
return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'is_admin' => \App\Http\Middleware\IsAdminMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { // })->create();
For Laravel 10 Middleware is registered in the app/Http/Kernel.php
.
app/Http/Kernel.php:
class Kernel extends HttpKernel{ // ... protected $routeMiddleware = [ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'is_admin' => \App\Http\Middleware\IsAdminMiddleware::class, ];}
Now, we can use this Middleware in the routes. We will separate routes from the resource. One Route group is for the auth
user, and the second is for is_admin
.
routes/web.php:
Route::resource('products', ProductController::class)->middleware('auth'); Route::middleware('auth')->group(function () { Route::get('products', [ProductController::class, 'index'])->name('products.index'); Route::middleware('is_admin')->group(function () { Route::get('products/create', [ProductController::class, 'create'])->name('products.create'); Route::post('products', [ProductController::class, 'store'])->name('products.store'); });});
The new product button is only visible to the user if is_admin
is true.
We also need to add methods in the Controller.
app/Http/Controllers/ProductController.php:
class ProductController extends Controller{ // ... public function create() { // } public function store() { // }}
Ok, the Laravel code is finally ready. Now, let's write tests for it.
We will add four tests: two for visual check and two for back-end restriction.
First, let's add two visual tests. The admin user should see the Add new product
button, while other users shouldn't.
feature/Tests/ProductsTest.php:
// ... test('admin can see products create button', function () { $admin = User::factory()->create(['is_admin' => true]); actingAs($admin) ->get('/products') ->assertStatus(200) ->assertSee('Add new product');}); test('non admin cannot see products create button', function () { actingAs($this->user) ->get('/products') ->assertStatus(200) ->assertDontSee('Add new product');});
We create an admin user manually and overwrite the is_admin
value.
Next, let's add the tests for accessing the page directly without using that button. If someone knows the URL, they still might try to access it.
tests/Feature/ProductsTest.php:
// ... test('admin can access product create page', function () { $admin = User::factory()->create(['is_admin' => true]); actingAs($admin) ->get('/products/create') ->assertStatus(200);}); test('non admin cannot access product create page', function () { actingAs($this->user) ->get('/products/create') ->assertStatus(403);});
The last thing is to refactor the repeating admin user creation. We can create a custom helper and call it asAdmin()
. Custom helpers are added as functions in the tests/Pest.php
file.
tests/Pest.php:
// ... function asAdmin(): TestCase{ $user = User::factory()->create([ 'is_admin' => true, ]); return test()->actingAs($user);}
Now, we can have a second private property, $admin
, and create the admin user the same way we did with a regular user.
tests/Feature/ProductsTest.php:
// ... test('admin can see products create button', function () { $admin = User::factory()->create(['is_admin' => true]); actingAs($admin) asAdmin() ->get('/products') ->assertStatus(200) ->assertSee('Add new product');}); test('non admin cannot see products create button', function () { actingAs($this->user) ->get('/products') ->assertStatus(200) ->assertDontSee('Add new product');}); test('admin can access product create page', function () { $admin = User::factory()->create(['is_admin' => true]); actingAs($admin) asAdmin() ->get('/products/create') ->assertStatus(200);}); test('non admin cannot access product create page', function () { actingAs($this->user) ->get('/products/create') ->assertStatus(403);});
First, the visual tests.
feature/Tests/ProductsTest.php:
class ProductsTest extends TestCase{ use RefreshDatabase; // ... public function test_admin_can_see_products_create_button() { $admin = User::factory()->create(['is_admin' => true]); $response = $this->actingAs($admin)->get('/products'); $response->assertStatus(200); $response->assertSee('Add new product'); } public function test_non_admin_cannot_see_products_create_button() { $response = $this->actingAs($this->user)->get('/products'); $response->assertStatus(200); $response->assertDontSee('Add new product'); } private function createUser(): User { return User::factory()->create(); }}
Next, tests for accessing the page if someone would know the URL and might try to access it.
tests/Feature/ProductsTest.php:
class ProductsTest extends TestCase{ use RefreshDatabase; // ... public function test_admin_can_access_product_create_page() { $admin = User::factory()->create(['is_admin' => true]); $response = $this->actingAs($admin)->get('/products/create'); $response->assertStatus(200); } public function test_non_admin_cannot_access_product_create_page() { $response = $this->actingAs($this->user)->get('/products/create'); $response->assertStatus(403); } private function createUser(): User { return User::factory()->create(); }}
The last thing is to refactor the admin user creation. In the createUser()
method, let's add a parameter isAdmin
, which will be false
by default.
tests/Feature/ProductsTest.php:
class ProductsTest extends TestCase{ // ... private function createUser(bool $isAdmin = false): User { return User::factory()->create([ 'is_admin' => $isAdmin, ]); }}
Now, we can have a second private property, $admin
, and create the admin user the same way we did with a regular user.
tests/Feature/ProductsTest.php:
class ProductsTest extends TestCase{ use RefreshDatabase; private User $user; private User $admin; protected function setUp(): void { parent::setUp(); $this->user = $this->createUser(); $this->admin = $this->createUser(isAdmin: true); } // ... public function test_admin_can_see_products_create_button() { $admin = User::factory()->create(['is_admin' => true]); $response = $this->actingAs($admin)->get('/products'); $response = $this->actingAs($this->admin)->get('/products'); $response->assertStatus(200); $response->assertSee('Add new product'); } public function test_non_admin_cannot_see_products_create_button() { $response = $this->actingAs($this->user)->get('/products'); $response->assertStatus(200); $response->assertDontSee('Add new product'); } public function test_admin_can_access_product_create_page() { $admin = User::factory()->create(['is_admin' => true]); $response = $this->actingAs($admin)->get('/products/create'); $response = $this->actingAs($this->admin)->get('/products/create'); $response->assertStatus(200); } public function test_non_admin_cannot_access_product_create_page() { $response = $this->actingAs($this->user)->get('/products/create'); $response->assertStatus(403); } private function createUser(bool $isAdmin = false): User { return User::factory()->create([ 'is_admin' => $isAdmin, ]); }}
In this case, we just added the is_admin
column to the users
table for simplicity. In real projects, you may have much more complex logic for roles/permissions, with or without external Laravel packages.
But, looking at it from a testing perspective, test methods don't really care about your logic. You just create a user with permissions, try to call the features, and assert if the status code is correct: 200, 403, or something else.