Courses

Laravel 11: Small Reservation Project Step-By-Step

Company Owner: Manages Users

Now that the administrator can add users to the company, we need to implement a feature where the company owners can add users to the company themselves.


Soft Delete Users

First, let's add SoftDeletes for the User Model if someone accidentally deletes a user. I personally do that for almost all DB tables in Laravel, my experience showed that such "just in case" paid off in case of emergencies too often.

php artisan make:migration "add soft deletes to users table"

database/migrations/xxxx_add_soft_deletes_to_users_table.php:

public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->softDeletes();
});
}

app/Models/User.php:

use Illuminate\Database\Eloquent\SoftDeletes;
 
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
use SoftDeletes;
 
// ...
}

Because of this added feature, the test test_user_can_delete_their_account from Laravel Breeze is now broken. Let's fix it.

tests/Feature/ProfileTest:

class ProfileTest extends TestCase
{
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
 
$response = $this
->actingAs($user)
->delete('/profile', [
'password' => 'password',
]);
 
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
 
$this->assertGuest();
$this->assertNull($user->fresh());
$this->assertSoftDeleted($user->fresh());
}
}

Great, now it's fixed!

> php artisan test --filter=test_user_can_delete_their_account
 
PASS Tests\Feature\ProfileTest
✓ user can delete their account 0.13s
 
Tests: 1 passed (5 assertions)
Duration: 0.15s

CRUD Actions

Now, let's move on to the main feature. First, let's show the new item Administrators in the navigation, which will be visible only for users with the role of Company Owner.

Notice: I know it sounds a bit confusing: internally we call those people "Company Owners" role but for them visually a better understandable word is "Administrators".

Let's add this new menu item after the menu "Companies". For permission check, I will just add a simple @if to check for the role_id.

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 === \App\Enums\Role::ADMINISTRATOR->value)
<x-nav-link :href="route('companies.index')" :active="request()->routeIs('companies.index')">
{{ __('Companies') }}
</x-nav-link>
@endif
@if(auth()->user()->role_id === \App\Enums\Role::COMPANY_OWNER->value)
<x-nav-link :href="route('companies.users.index', auth()->user()->company_id)" :active="request()->routeIs('companies.users.*')">
{{ __('Administrators') }}
</x-nav-link>
@endif
</div>
// ...

administrators for a company navigation

Now that we have the navigation link, let's implement the backend part.

We do have the CRUD Controller from the last lesson, but now we need to work on the permissions to "open up" that CRUD to another role.

So, first, let's create a Policy and register it in the AppServiceProvider.

php artisan make:policy CompanyUserPolicy --model=Company

app/Providers/AuthServiceProvider.php:

use App\Models\Company;
use App\Policies\CompanyUserPolicy;
use Illuminate\Support\Facades\Gate;
 
class AppServiceProvider extends ServiceProvider
{
// ...
 
public function boot(): void
{
Gate::policy(Company::class, CompanyUserPolicy::class);
}
}

The Policy class will contain methods to check various permissions:

  • viewAny
  • create
  • update
  • delete

And we will allow those actions based on user's role Company Owner and their company ID.

But for the administrator role, we need to just allow everything. So, I remembered the before Policy Filter method. In this method, we will just return true if the user has the role of administrator.

So, the whole policy code is below.

app/Policies/CompanyUserPolicy.php:

use App\Enums\Role;
use App\Models\User;
use App\Models\Company;
 
class CompanyUserPolicy
{
public function before(User $user): bool|null
{
if ($user->role_id === Role::ADMINISTRATOR->value) {
return true;
}
 
return null;
}
 
public function viewAny(User $user, Company $company): bool
{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $company->id;
}
 
public function create(User $user, Company $company): bool
{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $company->id;
}
 
public function update(User $user, Company $company): bool
{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $company->id;
}
 
public function delete(User $user, Company $company): bool
{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $company->id;
}
}

Next, in the CompanyUserController, we need to do the authorize check for each CRUD action. There are a couple of ways to do that, but I will use the authorize method via Gate Facade.

For other authorizing ways, check the official documentation.

app/Http/Controllers/CompanyUserController.php:

use Illuminate\Support\Facades\Gate;
 
class CompanyUserController extends Controller
{
public function index(Company $company)
{
Gate::authorize('viewAny', $company);
 
// ...
}
 
public function create(Company $company)
{
Gate::authorize('create', $company);
 
// ...
}
 
public function store(StoreUserRequest $request, Company $company)
{
Gate::authorize('create', $company);
 
// ...
}
 
public function edit(Company $company, User $user)
{
Gate::authorize('update', $company);
 
// ...
}
 
public function update(UpdateUserRequest $request, Company $company, User $user)
{
Gate::authorize('update', $company);
 
// ...
}
 
public function destroy(Company $company, User $user)
{
Gate::authorize('delete', $company);
 
// ...
}
}

Great! Now users with the Company Owner role can create new users for their company and cannot do any CRUD actions for other companies.


Tests

So now we made some changes to the CompanyUserController and added additional authorization. First, let's check if we didn't break anything for the users with the administrator role.

> php artisan test --filter=CompanyUserTest
 
PASS Tests\Feature\CompanyUserTest
✓ admin can access company users page 0.09s
✓ admin can create user for a company 0.02s
✓ admin can edit user for a company 0.01s
✓ admin can delete user for a company 0.01s
 
Tests: 4 passed (10 assertions)
Duration: 0.16s

Great! All tests are green.

Now let's add more tests to the CompanyUserTest. We will check if the user with the Company Owner role can do CRUD actions for his company and cannot do any for other companies.

Before adding the tests, we need to add another Factory State for the Company Owner role.

database/factories/UserFactory.php:

class UserFactory extends Factory
{
// ...
 
public function companyOwner(): static
{
return $this->state(fn (array $attributes) => [
'role_id' => Role::COMPANY_OWNER->value,
]);
}
}

And the tests themselves.

tests/Feature/CompanyUserTest.php:

class CompanyUserTest extends TestCase
{
// ...
 
public function test_company_owner_can_view_his_companies_users()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
$secondUser = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->get(route('companies.users.index', $company->id));
 
$response->assertOk()
->assertSeeText($secondUser->name);
}
 
public function test_company_owner_cannot_view_other_companies_users()
{
$company = Company::factory()->create();
$company2 = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->get(route('companies.users.index', $company2->id));
 
$response->assertForbidden();
}
 
public function test_company_owner_can_create_user_to_his_company()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->post(route('companies.users.store', $company->id), [
'name' => 'test user',
'email' => '[email protected]',
'password' => 'password',
]);
 
$response->assertRedirect(route('companies.users.index', $company->id));
 
$this->assertDatabaseHas('users', [
'name' => 'test user',
'email' => '[email protected]',
'company_id' => $company->id,
]);
}
 
public function test_company_owner_cannot_create_user_to_other_company()
{
$company = Company::factory()->create();
$company2 = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->post(route('companies.users.store', $company2->id), [
'name' => 'test user',
'email' => '[email protected]',
'password' => 'password',
]);
 
$response->assertForbidden();
}
 
public function test_company_owner_can_edit_user_for_his_company()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->put(route('companies.users.update', [$company->id, $user->id]), [
'name' => 'updated user',
'email' => '[email protected]',
]);
 
$response->assertRedirect(route('companies.users.index', $company->id));
 
$this->assertDatabaseHas('users', [
'name' => 'updated user',
'email' => '[email protected]',
'company_id' => $company->id,
]);
}
 
public function test_company_owner_cannot_edit_user_for_other_company()
{
$company = Company::factory()->create();
$company2 = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->put(route('companies.users.update', [$company2->id, $user->id]), [
'name' => 'updated user',
'email' => '[email protected]',
]);
 
$response->assertForbidden();
}
 
public function test_company_owner_can_delete_user_for_his_company()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->delete(route('companies.users.update', [$company->id, $user->id]));
 
$response->assertRedirect(route('companies.users.index', $company->id));
 
$this->assertDatabaseMissing('users', [
'name' => 'updated user',
'email' => '[email protected]',
]);
}
 
public function test_company_owner_cannot_delete_user_for_other_company()
{
$company = Company::factory()->create();
$company2 = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$response = $this->actingAs($user)->delete(route('companies.users.update', [$company2->id, $user->id]));
 
$response->assertForbidden();
}
}

Good. All the tests passed!

Previous: Admin: Managing Users
avatar

Grate class having a lot of fun learning new things at least for me. However on this part of the test I am not getting all green I have one error that I am having a bit of trouble figuring out

public function test_company_owner_cannot_edit_user_for_other_company()
    {
        $company = Company::factory()->create();
        $company2 = Company::factory()->create();
        $user = User::factory()->companyOwner()->create(['company_id' => $company->id]);

        $response = $this->actingAs($user)->put(route('companies.users.update', [$company2->id, $user->id]), [
            'name' => 'updated user',
            'email' => 'test@update.com',
        ]);

        $response->assertForbidden();
    }

  FAILED  Tests\Feature\CompanyUserTest > company owner cannot edit user for other company
  Expected response status code [403] but received 302.
Failed asserting that 403 is identical to 302.

  at tests\Feature\CompanyUserTest.php:167
    163▕             'name' => 'updated user',
    164▕             'email' => 'test@update.com',
    165▕         ]);
    166▕
   167▕         $response->assertForbidden();
    168▕     }
    169▕
    170▕     public function test_company_owner_can_delete_user_for_his_company()
    171▕     {


  Tests:    1 failed, 11 passed (25 assertions)
  Duration: 0.85s


 public function edit(Company $company, User $user)
    {
        $this->authorize('update', $company);

        return view('companies.users.edit', compact('company', 'user'));
    }

Can’t find the edit in the policies\Company User Policy page

avatar

I started over from scratch and BOY AM I GLADE! All test passed even the one on this page.

avatar
You can use Markdown
avatar

Small things, in these methods:

test_company_owner_cannot_delete_user_for_other_company

test_company_owner_can_delete_user_for_his_company

test_admin_can_delete_user_for_a_company

( route name should be destroy not update )

$response = $this->actingAs($user)->delete(route('companies.users.destroy', [$company2->id, $user->id]));

avatar

Updated tutorial. Thanks

avatar

Hi, it seems that in an update of the course the update route name is being used again in de delete tests.

avatar
You can use Markdown
avatar

Shouldn't we use $this->assertSoftDeleted within test_company_owner_can_delete_user_for_his_company?

avatar

Yes it would be logical.

avatar
You can use Markdown
avatar

question, why did you use the ff code in the navigation section:

request()->routeIs('companies.users.*')

instead of:

request()->routeIs('companies.users.index', auth()->user()->company_id)

is it to make the code shorter?

avatar

RouteIs checks if the current URL is the one user is on right now. The * means everything, so every route name that starts with companies. users. will be a true value and navigation item will be set as active. What you are saying is a route model binding

avatar

To just make it more obvious:

companies.users.index will only match that route. But what if you go inside the create/edit pages? Then the route is not going to be marked as active. This means that the navigation will be in an incorrect state.

Now adding the * to the url as companies.users.* will make it match ANYTHING that is inside there. For example: companies.users.index companies.users.create companies.users.edit

All of the above will match and correctly mark the active navigation due to the * wildcard

avatar

thanks guys!

avatar
You can use Markdown
avatar

i got an error after runing the test.

An error occurred inside PHPUnit.

Message: syntax error, unexpected token "use" Location: C:\laragon\www\reservation\tests\Feature\CompanyTest.php:17

avatar

Its like your test file has syntax errors

avatar
You can use Markdown
avatar

Soo many new things to learn ... daaimn :D. Yet it's still is fun

avatar
You can use Markdown
avatar
You can use Markdown