After creating the companies CRUD, the next step is to give access to this CRUD only for users with the Administrator
role. For this, we will create a Middleware.
Middleware isAdmin
So, first, we need to create a Middleware and assign a name to it in the Kernel file. We will call it isAdmin
.
php artisan make:middleware IsAdminMiddleware
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([ 'isAdmin' => \App\Http\Middleware\IsAdminMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { // })->create();
We will abort the request in the Middleware if the user doesn't have an administrator
role.
App/Http/Middleware/IsAdminMiddleware.php:
use App\Enums\Role;use Symfony\Component\HttpFoundation\Response; class IsAdminMiddleware{ public function handle(Request $request, Closure $next): Response { abort_if($request->user()->role_id !== Role::ADMINISTRATOR->value, Response::HTTP_FORBIDDEN); return $next($request); }}
Notice: I prefer to suffix all filenames in Laravel with their purpose, so AbcMiddleware
will immediately tell us what that file does. When naming it in the Kernel, you can skip this suffix and shorten it however you want, like isAdmin
in my case.
Next, we need to add this Middleware to the companies Route.
routes/web.php:
Route::middleware('auth')->group(function () { // ... Route::resource('companies', CompanyController::class); Route::resource('companies', CompanyController::class)->middleware('isAdmin'); });
Now, if you visit the companies page as a registered user, you will get a Forbidden
page because the default role of users is customer, not the administrator.
Menu Item: Only For Administrators
Next, we must hide Companies
in the navigation menu for everyone except the administrator
role users.
We could create a custom Blade Directive, but for now, we will just use a simple @if
in Blade.
Later, if we see that we are repeating this check, we will create a dedicated Blade directive.
resources/views/layouts/navigation.blade.php:
// ...<!-- Navigation Links --><div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> {{ __('Dashboard') }} </x-nav-link> @if(auth()->user()->role_id === 1) <x-nav-link :href="route('companies.index')" :active="request()->routeIs('companies.index')"> {{ __('Companies') }} </x-nav-link> @endif </div>// ...
So now, other users don't see the Companies
in the navigation.
Automated Tests
My personal philosophy with automated tests is that you need to start writing them almost from the very beginning, feature by feature, immediately after you finish a certain clear part of that feature.
Some people prefer TDD to write tests first, but for me personally, it never worked well, cause in many cases, you don't have the full clearance on what the feature should look like in its final version. Which then leads to double work of editing both the code and the tests multiple times.
Other people prefer to write tests after the project is done, but in that case you may likely forget the details of how certain features work, especially the ones you created long time ago.
Now, let's start with writing tests for permission: to ensure that only users with the administrator
role can access the companies
page.
But before that, we need to fix the default tests from Laravel Breeze. When we added the role_id
column to the Users
table, it broke the default breeze tests:
FAILED Tests\Feature\Auth\AuthenticationTest > users can authenticate using the login screen QueryException SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.role_id (Connection: sqlite, SQL: insert into "users" ("name", "email", "email_verified_at", "password", "remember_token", "updated_at", "created_at") values (Dolores Sauer, [email protected], 2023-05-12 07:33:43, $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi, 0kYt26ciDo, 2023-05-12 07:33:43, 2023-05-12 07:33:43)) at vendor/laravel/framework/src/Illuminate/Database/Connection.php:578 574▕ $this->bindValues($statement, $this->prepareBindings($bindings)); 575▕ 576▕ $this->recordsHaveBeenModified(); 577▕ ➜ 578▕ return $statement->execute(); 579▕ }); 580▕ } 581▕ 582▕ /** +15 vendor frames 16 tests/Feature/Auth/AuthenticationTest.php:23
We need to add the role_id
to the UserFactory
to fix it. And while we are at the UserFactory
, let's add a Factory State for easier administrator
user creation.
database/factories/UserFactory.php:
use App\Enums\Role; class UserFactory extends Factory{ public function definition(): array { return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 'remember_token' => Str::random(10), 'role_id' => Role::CUSTOMER->value, ]; } // ... public function admin(): static { return $this->state(fn (array $attributes) => [ 'role_id' => Role::ADMINISTRATOR->value, ]); } }
Also, we must tell the tests to seed roles every time.
tests/TestCase.php:
use Database\Seeders\RoleSeeder;use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase{ use CreatesApplication; protected bool $seed = true; }
Now the tests passes.
Tests: 24 passed (56 assertions)Duration: 1.07s
So now, we can create our tests.
php artisan make:test CompanyTest
This file of CompanyTest
will contain all the methods related to managing companies.
tests/Feature/CompanyTest.php:
use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class CompanyTest extends TestCase{ use RefreshDatabase; public function test_admin_user_can_access_companies_index_page(): void { $user = User::factory()->admin()->create(); $response = $this->actingAs($user)->get(route('companies.index')); $response->assertOk(); } public function test_non_admin_user_cannot_access_companies_index_page(): void { $user = User::factory()->create(); $response = $this->actingAs($user)->get(route('companies.index')); $response->assertForbidden(); }}
So, what do we do in these tests?
- First, because in the tests we are working with the database, we enable the
RefreshDatabase
trait. But don't forget to edit yourphpunit.xml
to ensure you're working on the testing database and not live! - Next, we create a user. In the first test, we use the earlier added
admin()
state from the Factory. - Then, acting as a newly-created user, we go to the
companies.index
route. - Ant last, we check the response. The administrator will get a response HTTP status of
200 OK
, and other users will receive an HTTP status of403 Forbidden
.
Hello, The test failed at my end, I had to remove the constrain at the add_role_id_to_users_table, then everything went as expected.
the tests failed at my end too so i just removed the constrained from the role_id
Just reran this test and it's all green for me. Maybe you are missing something. Without some code and error message can't help.
This is the error: SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (
testing_reservation_project
.users
, CONSTRAINTusers_role_id_foreign
FOREIGN KEY (role_id
) REFERENCESroles
(id
)) (Connection: mysql, SQL: insert intousers
(name
,email
,email_verified_at
,password
,remember_token
,role_id
,updated_at
,created_at
) values (Emely Greenfelder IV, konopelski.edwin@example.com, 2023-07-08 20:59:46, $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi, 56Sx9OSLgp, 3, 2023-07-08 20:59:46, 2023-07-08 20:59:46))My guess is you don't have roles seeded. Constraints should be always used.
The problem is that every Test is using
use RefreshDatabase;
to refresh tables after testing, in my case by default it´s not seeding at all, just migrating, so I just addpublic $seed = true;
in every single test to let know that I want to seed tables as well, this works for me and didn´t have to remove constrained from migration.Solved it in last comment on: https://laracasts.com/discuss/channels/testing/manually-seed-refreshdatabase
You don't need to add it in every test. Add in the
TestCase.php
and it will work for every test.This worked for me
@jocagutierrezz youre the goat man thanks
@Ahmad add role_id to your DatabaseSeeder just like this.
$this->call(RoleSeeder::class);
User::factory()->create([ 'role_id' => 1, ]);
Need to create a database to run the tests in laravel?
Yes it should be either a separate MySQL database, or SQLite database in memory. Watch this lesson from my course about testing for beginners.