Back to Course |
Roles and Permissions in Laravel 11

Master Admin: Managing Teams/Clinics

The teams (clinics) will be managed by a user with a Master Admin role. That Master Admin will not see the users and the tasks of each clinic. They will just manage the teams.

For simplicity, in this tutorial, we will build just the team features of list and create, without edit/delete functionality.

First, let's talk about roles and permissions since they are the main topic of this course.

The rules will be defined in the Policy file we generate specifically for Team management.

php artisan make:policy TeamPolicy

app/Policies/TeamPolicy.php

use App\Models\User;
use App\Enums\Permission;
use Illuminate\Auth\Access\HandlesAuthorization;
 
class TeamPolicy
{
use HandlesAuthorization;
 
public function viewAny(User $user): bool
{
return $user->hasPermissionTo(Permission::LIST_TEAM);
}
 
public function create(User $user): bool
{
return $user->hasPermissionTo(Permission::CREATE_TEAM);
}
}

We use the permission names (Enum again!). We have already assigned the roles for those permissions in the seeders.

Laravel will automatically detect the Policy by the Model, so we can use those Policy checks immediately in our new Controller, with Gate::authorize() in each method.

php artisan make:controller TeamController

We will add three methods inside:

app/Http/Controllers/TeamController.php

use App\Models\Team;
use Illuminate\Support\Facades\Gate;
 
class TeamController extends Controller
{
public function index(): View
{
Gate::authorize('viewAny', Team::class);
 
// Coming soon.
}
 
public function create(): View
{
Gate::authorize('create', Team::class);
 
// Coming soon.
}
 
public function store(StoreTeamRequest $request): RedirectResponse
{
Gate::authorize('create', Team::class);
 
// Coming soon.
}
}

Notice: It's a personal preference whether to use Policies or check the Permissions directly in the Controller. For this relatively simple check, Policies are NOT necessary. You could check this directly in the Controller:

public function index(): View
{
Gate::authorize(Permission::LIST_TEAM);

However, in this project, we decided to go with Policies because they may contain more complex checks. It's a better practice not to "pollute" Controllers with more logic.

Next, we will add the Routes.

routes/web.php

Route::middleware('auth')->group(function () {
// ...
 
Route::resource('teams', TeamController::class)
->only(['index', 'create', 'store']);

Next, we add the navigation item at the top.

resources/views/layouts/navigation.blade.php

<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
 
@can(\App\Enums\Permission::LIST_TEAM)
<x-nav-link :href="route('teams.index')"
:active="request()->routeIs('teams.*')">
{{ __('Clinics') }}
</x-nav-link>
@endcan
</div>

See, we're using the Permission Enum again!

Next, one by one, we fill the Controller methods with code.

Listing the teams is easy. We just need to filter out the "fake" Master Admin team.

app/Http/Controllers/TeamController.php

public function index(): View
{
$teams = Team::where('name', '!=', 'Master Admin Team')->paginate();
 
return view('teams.index', compact('teams'));
}

The Blade file is a typical table, and I will skip the longer CSS classes with "..." for readability:

resources/views/teams/index.blade.php

<x-app-layout>
<x-slot name="header">
<h2 class="...">
{{ __('All Clinics') }}
</h2>
</x-slot>
 
<div class="py-12">
...
 
@can(\App\Enums\Permission::CREATE_TEAM)
<a href="{{ route('teams.create') }}" class="underline">Add new clinic</a>
<br /><br />
@endcan
 
<table class="...">
<thead>
<tr>
<th ...>Name</th>
<th ...></th>
</tr>
</thead>
 
<tbody class="..."">
@foreach($teams as $team)
<tr class="...">
<td class="...">
{{ $team->name }}
</td>
</tr>
@endforeach
</tbody>
</table>
 
...
</div>
</x-app-layout>

Again, the Permission Enum to show/hide the link to create a new team!

Visual result when we log in with a Master Admin:

Next, creating a new team/clinic.

Since the owner may have multiple clinics, I decided to have a choice here in the form:

  • Either choose the owner from the dropdown list
  • Or to create a new owner user by providing their name/email/password

Here's what the result will look like:

Now, the code. For the form, we need to get all the Clinic Owners from the database. And here's an important thing you need to know about how the Spatie Permission package works.

When you add the use HasRoles' option to your User Model, a relationship method roles()` is automatically added.

The official docs of the package suggest using it like this:

$allUsersWithAllTheirRoles = User::with('roles')->get();

So, I tried this Controller code:

app/Http/Controllers/TeamController.php

use App\Enums\Role as RoleEnum;
use App\Models\User;
use Illuminate\View\View;
 
// ...
 
public function create(): View
{
$users = User::whereRelation('roles', 'name', '=', RoleEnum::ClinicOwner->value)
->pluck('name', 'id');
 
return view('teams.create', compact('users'));
}

But, for some reason, I didn't get any users. Although there was definitely a Clinic Owner seeded in the DB, we did it just recently.

Then, I realized that it's automatically filtering users by team of current user.

Here's the original source code of that roles() method:

vendor/spatie/laravel-permission/src/Traits/HasRoles.php

public function roles(): BelongsToMany
{
$relation = $this->morphToMany(
config('permission.models.role'),
'model',
config('permission.table_names.model_has_roles'),
config('permission.column_names.model_morph_key'),
app(PermissionRegistrar::class)->pivotRole
);
 
if (! app(PermissionRegistrar::class)->teams) {
return $relation;
}
 
$teamsKey = app(PermissionRegistrar::class)->teamsKey;
$relation->withPivot($teamsKey);
$teamField = config('permission.table_names.roles').'.'.$teamsKey;
 
return $relation->wherePivot($teamsKey, getPermissionsTeamId())
->where(fn ($q) => $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId()));
}

See that $relation->wherePivot()? So, it's trying to get the clinic owners of the same team as Master Admin. This doesn't make sense, as we want all the clinic owners, regardless of their teams.

So, I had to improvise and create a separate custom relationship method in the User model, leaving just the first part:

app/Models/User.php

use Illuminate\Database\Eloquent\Relations\MorphToMany;
 
// ...
 
public function rolesWithoutTeam(): MorphToMany
{
return $this->morphToMany(
config('permission.models.role'),
'model',
config('permission.table_names.model_has_roles'),
config('permission.column_names.model_morph_key'),
app(PermissionRegistrar::class)->pivotRole
);
}

And then, the Controller code became this:

public function create(): View
{
$users = User::whereRelation('rolesWithoutTeam', 'name', '=', RoleEnum::ClinicOwner->value)
->pluck('name', 'id');
 
return view('teams.create', compact('users'));
}

The Blade file is a typical form, reusing Blade components from Laravel Breeze.

We're showing both options as visible when choosing the old/new clinic owner. I decided not to add any JS/Livewire for dynamic choice (like the New User form would appear only when some dropdown value is chosen), as JS/Livewire is outside the scope of this already huge project.

resources/views/teams/create.blade.php

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('New Clinic') }}
</h2>
</x-slot>
 
<div class="py-12">
 
...
<form method="POST" action="{{ route('teams.store') }}">
@csrf
 
<!-- Clinic Name -->
<div class="mt-4">
<x-input-label for="clinic_name" :value="__('Clinic Name')" />
<x-text-input id="clinic_name" class="mt-1 block w-full" type="text" name="clinic_name" :value="old('clinic_name')" required />
<x-input-error :messages="$errors->get('clinic_name')" class="mt-2" />
</div>
 
<h3 class="mt-4 text-lg font-semibold">Select User</h3>
 
<!-- Clinic Owner -->
<div class="mt-4">
<x-input-label for="user_id" :value="__('User')" />
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">-- SELECT USER --</option>
@foreach($users as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('user_id')" class="mt-2" />
</div>
 
<h3 class="mt-4 text-lg font-semibold">Or Create a new User</h3>
 
<!-- Name -->
<div class="mt-4">
<x-input-label for="name" :value="__('User Name')" />
<x-text-input id="name" class="mt-1 block w-full" type="text" name="name" :value="old('name')" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
 
<!-- Email -->
<div class="mt-4">
<x-input-label for="email" :value="__('User Email')" />
<x-text-input id="email" class="mt-1 block w-full" type="text" name="email" :value="old('email')" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
 
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('User Password')" />
<x-text-input id="password" class="mt-1 block w-full" type="password" name="password" :value="old('password')" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
 
<x-primary-button class="mt-4">
{{ __('Save') }}
</x-primary-button>
</form>
 
...
</div>
</x-app-layout>

Now, saving the new team/clinic into the DB.

First, the validation Form Request:

php artisan make:request StoreTeamRequest

app/Http/Requests/StoreTeamRequest.php

use Illuminate\Validation\Rules\Password;
use Illuminate\Foundation\Http\FormRequest;
 
class StoreTeamRequest extends FormRequest
{
public function rules(): array
{
return [
'clinic_name' => ['required'],
'user_id' => ['nullable', 'required_without_all:name,email,password', 'exists:users,id'],
 
'name' => ['nullable', 'required_without:user_id', 'required_with:email,password', 'string'],
'email' => ['nullable', 'required_without:user_id', 'required_with:name,password', 'email'],
'password' => ['nullable', 'required_without:user_id', 'required_with:name,email', 'string', Password::defaults()],
];
}
 
public function authorize(): bool
{
return true;
}
}

As you can see, we have validation rules required_with and required_without_all for fields depending on other fields. You can find the list of all available Laravel validation rules here in the docs.

Then, we use that Form Request file in the Controller, create the Team, and then redirect back to the list.

app/Http/Controllers/TeamController.php:

use App\Http\Requests\StoreTeamRequest;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Spatie\Permission\Models\Role as RoleModel;
 
// ...
 
public function store(StoreTeamRequest $request): RedirectResponse
{
$team = Team::create(['name' => $request->input('clinic_name')]);
 
if ($request->integer('user_id') > 0) {
$user = User::find($request->integer('user_id'));
$user->update(['current_team_id' => $team->id]);
} else {
$user = User::create($request->only(['name', 'email', 'password'])
+ ['current_team_id' => $team->id]);
}
 
$user->teams()
->attach($team->id, [
'model_type' => User::class,
'role_id' => RoleModel::where('name', RoleEnum::ClinicOwner->value)->first()->id,
]);
 
return redirect()->route('teams.index');
}

Finally, let's create automated tests for those actions.

php artisan make:test TeamTest

tests/Feature/TeamTest.php

use App\Models\User;
use App\Models\Team;
use function Pest\Laravel\actingAs;
 
it('allows to create a new team and assign existing user', function () {
$masterAdmin = User::factory()->masterAdmin()->create();
$clinicOwner = User::factory()->clinicOwner()->create();
 
actingAs($masterAdmin)
->post(route('teams.store'), [
'clinic_name' => 'New Team',
'user_id' => $clinicOwner->id,
]);
 
$newTeam = Team::where('name', 'New Team')->first();
 
expect($clinicOwner->belongsToTeam($newTeam))->toBeTrue();
});
 
it('allows to create a new team with a new user', function () {
$masterAdmin = User::factory()->masterAdmin()->create();
 
actingAs($masterAdmin)
->post(route('teams.store'), [
'clinic_name' => 'New Team',
'name' => 'New User',
'email' => 'new@user.com',
'password' => 'password',
]);
 
$newTeam = Team::where('name', 'New Team')->first();
$newUser = User::where('email', 'new@user.com')->first();
 
expect($newUser->belongsToTeam($newTeam))->toBeTrue();
});

Here's the result: