Before implementing the creation of the users with the role of Guide
, we asked the client one question again.
Question: Who will be able to manage guides? Only company owners? Or would administrators need this feature, too? Answer: No, only company owners will manage guides for their company. What it means to us: No additional changes to the structure are needed.
From this answer, we now know that a lot of code made within the previous CRUD for Company Owner
can be reused. It's the same managing of Users, just with a different role.
This is how projects are usually created: you uncover feature after feature, looking back to see if you can reuse previous functionality or need to perform some code refactoring with each "new layer".
Guides CRUD actions
So, as always, we need a Controller and to add Routes. Again this Controller will be a Nested Resource.
php artisan make:controller CompanyGuideController
routes/web.php
use App\Http\Controllers\CompanyGuideController; Route::middleware('auth')->group(function () { // ... Route::resource('companies.users', CompanyUserController::class)->except('show'); Route::resource('companies.guides', CompanyGuideController::class)->except('show'); });
For the navigation, we will add it after the Administrators
link in the same if
statement.
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> <x-nav-link :href="route('companies.guides.index', auth()->user()->company_id)" :active="request()->routeIs('companies.guides.*')"> {{ __('Guides') }} </x-nav-link> @endif</div>// ...
For the validation, we will again use the Form Requests.
php artisan make:request StoreGuideRequestphp artisan make:request UpdateGuideRequest
app/Http/Requests/StoreGuideRequest.php:
use Illuminate\Validation\Rules; class StoreGuideRequest extends FormRequest{ public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required', 'string'], 'email' => ['required', 'email', 'unique:users,email'], 'password' => ['required', Rules\Password::defaults()], ]; }}
app/Http/Requests/UpdateGuideRequest.php:
class UpdateGuideRequest extends FormRequest{ public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required', 'string'], 'email' => ['required', 'email', 'unique:users,email,' . $this->guide->id], ]; }}
The Controller is almost identical to the CompanyUserController
. We can even reuse the same Policy for the permissions. We must change everything from users
to guides
.
app/Http/Controllers/CompanyGuideController.php:
use App\Enums\Role;use App\Models\User;use App\Models\Company;use Illuminate\Support\Facades\Gate;use App\Http\Requests\StoreGuideRequest;use App\Http\Requests\UpdateGuideRequest; class CompanyGuideController extends Controller{ public function index(Company $company) { Gate::authorize('viewAny', $company); $guides = $company->users()->where('role_id', Role::COMPANY_OWNER->value)->get(); return view('companies.guides.index', compact('company', 'guides')); } public function create(Company $company) { Gate::authorize('create', $company); return view('companies.guides.create', compact('company')); } public function store(StoreGuideRequest $request, Company $company) { Gate::authorize('create', $company); $company->users()->create([ 'name' => $request->input('name'), 'email' => $request->input('email'), 'password' => bcrypt($request->input('password')), 'role_id' => Role::GUIDE->value, ]); return to_route('companies.guides.index', $company); } public function edit(Company $company, User $guide) { Gate::authorize('update', $company); return view('companies.guides.edit', compact('company', 'guide')); } public function update(UpdateGuideRequest $request, Company $company, User $guide) { Gate::authorize('update', $company); $guide->update($request->validated()); return to_route('companies.guides.index', $company); } public function destroy(Company $company, User $guide) { Gate::authorize('delete', $company); $guide->delete(); return to_route('companies.guides.index', $company); }}
For the views, because this is again a Nested Controller and belongs to a Company, all Blade files will be saved in the resources/views/companies/guides
directory. Here are the Blade files for listing, creating, and editing guides.
resources/views/companies/guides/index.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Company guides') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="overflow-hidden overflow-x-auto border-b border-gray-200 bg-white p-6"> <a href="{{ route('companies.guides.create', $company) }}" class="mb-4 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"> Create </a> <div class="min-w-full align-middle"> <table class="min-w-full border divide-y divide-gray-200"> <thead> <tr> <th class="bg-gray-50 px-6 py-3 text-left"> <span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500">Name</span> </th> <th class="w-56 bg-gray-50 px-6 py-3 text-left"> </th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> @foreach($guides as $guide) <tr class="bg-white"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $guide->name }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> <a href="{{ route('companies.guides.edit', [$company, $guide]) }}" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"> Edit </a> <form action="{{ route('companies.guides.destroy', [$company, $guide]) }}" method="POST" onsubmit="return confirm('Are you sure?')" style="display: inline-block;"> @csrf @method('DELETE') <x-danger-button> Delete </x-danger-button> </form> </td> </tr> @endforeach </tbody> </table> </div> </div> </div> </div> </div></x-app-layout>
resources/views/companies/guides/create.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Create Guide User for Company') }}: {{ $company->name }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="overflow-hidden overflow-x-auto border-b border-gray-200 bg-white p-6"> <form action="{{ route('companies.guides.store', $company) }}" method="POST"> @csrf <div> <x-input-label for="name" value="Name" /> <x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block mt-1 w-full" /> <x-input-error :messages="$errors->get('name')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="email" value="Email" /> <x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="block mt-1 w-full" /> <x-input-error :messages="$errors->get('email')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="password" value="Password" /> <x-text-input id="password" name="password" value="{{ old('password') }}" type="password" class="block mt-1 w-full" /> <x-input-error :messages="$errors->get('password')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form> </div> </div> </div> </div></x-app-layout>
resources/views/companies/guides/edit.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Edit User') }}: {{ $user->name }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="overflow-hidden overflow-x-auto border-b border-gray-200 bg-white p-6"> <form action="{{ route('companies.users.update', [$company, $user]) }}" method="POST"> @csrf @method('PUT') <div> <x-input-label for="name" value="Name" /> <x-text-input id="name" name="name" value="{{ old('name', $user->name) }}" type="text" class="block mt-1 w-full" /> <x-input-error :messages="$errors->get('name')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="email" value="Email" /> <x-text-input id="email" name="email" value="{{ old('email', $user->email) }}" type="text" class="block mt-1 w-full" /> <x-input-error :messages="$errors->get('email')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form> </div> </div> </div> </div></x-app-layout>
Tests
Before adding the tests, again, we need to add another Factory State for the Guide
role.
database/factories/UserFactory.php:
use App\Enums\Role; class UserFactory extends Factory{ // ... public function guide(): static { return $this->state(fn (array $attributes) => [ 'role_id' => Role::GUIDE->value, ]); }}
Now, we can create the test.
php artisan make:test CompanyGuideTest
What we will test is identical 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.
tests/Feature/CompanyGuideTest.php:
use App\Models\User;use App\Models\Company;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class CompanyGuideTest extends TestCase{ use RefreshDatabase; public function test_company_owner_can_view_his_companies_guides() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $secondUser = User::factory()->guide()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->get(route('companies.guides.index', $company->id)); $response->assertOk() ->assertSeeText($secondUser->name); } public function test_company_owner_cannot_view_other_companies_guides() { $company = Company::factory()->create(); $company2 = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->get(route('companies.guides.index', $company2->id)); $response->assertForbidden(); } public function test_company_owner_can_create_guide_to_his_company() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->post(route('companies.guides.store', $company->id), [ 'name' => 'test user', 'password' => 'password', ]); $response->assertRedirect(route('companies.guides.index', $company->id)); $this->assertDatabaseHas('users', [ 'name' => 'test user', 'company_id' => $company->id, ]); } public function test_company_owner_cannot_create_guide_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.guides.store', $company2->id), [ 'name' => 'test user', 'password' => 'password', ]); $response->assertForbidden(); } public function test_company_owner_can_edit_guide_for_his_company() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->put(route('companies.guides.update', [$company->id, $guide->id]), [ 'name' => 'updated user', ]); $response->assertRedirect(route('companies.guides.index', $company->id)); $this->assertDatabaseHas('users', [ 'name' => 'updated user', 'company_id' => $company->id, ]); } public function test_company_owner_cannot_edit_guide_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.guides.update', [$company2->id, $user->id]), [ 'name' => 'updated user', ]); $response->assertForbidden(); } public function test_company_owner_can_delete_guide_for_his_company() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->delete(route('companies.guides.update', [$company->id, $guide->id])); $response->assertRedirect(route('companies.guides.index', $company->id)); $this->assertSoftDeleted($guide); } public function test_company_owner_cannot_delete_guide_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.guides.update', [$company2->id, $user->id])); $response->assertForbidden(); }}
Great! It's all green.
Final notice for this lesson: you probably have noticed that functionality for managing guides is almost identical to managing company owners in the previous lessons. So wouldn't it be better to refactor the code and make it into one CRUD with some parameter like role_id
?
Yes and no. It depends on the specific situation: in this case, the code parts are identical for now. But there's a big possibility that Guides will have their own extra fields and logic in the future, like uploading their photo or CV, languages spoken, etc. So, I decided to keep those CRUDs separate.
This is a weird one at least for me 7 passed and 1 failed that one was the first one. Any suggestions?
PS the /n goes on for quite a wile like 20 to 30 lines.
could the problem be the fact that I don't have a second user? but would't that be supplied by the UserFactory or the CompanyFactory ?
In CompanyGuideController index method
should query the guide role id like this:
$guides = $company->users()->where('role_id', RoleEnum::GUIDE->value)->get();
Either its me, or shouldnt the last two tests be written like this?
public function test_company_owner_can_delete_guides_from_their_company() { $company = Company::factory()->create();
In resources/views/companies/guides/edit.blade.php
Undefined variable $user should be $guide
Updated lesson. Thanks.
in
resources/views/companies/guides/edit.blade.php:
line 12 the action route for the form must becompanies.guides.update
instead ofcompanies.users.update
.Thank you, well noticed, fixed it!
In the tests of this section: Wouldn't it be more explicit to check, if the company owner can edit a guide and not himself?
Before:
After:
The same for the other. Plus and an additional point: It should be tested, if the actual data of the user/guide is removed, that was created with the factory. With
assertDatabaseMissing()
the test would would always pass, because the given data was never in the database. And because auf the introduction of soft deletes a few chapters back, it should also be replaced byassertSoftDeleted()
.Before:
After:
Hi, is there a different in using:
$response = $this->actingAs($user)->get(route('companies.guides.index', $company->id));
or
$response = $this->actingAs($user)->get(route('companies.guides.index', $company));
in both cases the test passes without errors, how laravel interprets them? thanks
It is how route model binding wourks and has nothing to do with tests itself. You can read more about it in the laravel
StoreGuideRequest is missing use statement for Illuminate\Validation\Rules. This should be explicit for the users that do not use LSP.