Courses

Laravel 11: Small Reservation Project Step-By-Step

Managing Guides

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>
// ...

guides link visible only for company owner

For the validation, we will again use the Form Requests.

php artisan make:request StoreGuideRequest
php 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>

listing company guides


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',
'email' => '[email protected]',
'password' => 'password',
]);
 
$response->assertRedirect(route('companies.guides.index', $company->id));
 
$this->assertDatabaseHas('users', [
'name' => 'test user',
'email' => '[email protected]',
'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',
'email' => '[email protected]',
'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',
'email' => '[email protected]',
]);
 
$response->assertRedirect(route('companies.guides.index', $company->id));
 
$this->assertDatabaseHas('users', [
'name' => 'updated user',
'email' => '[email protected]',
'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',
'email' => '[email protected]',
]);
 
$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.

Previous: Company Owner: Manages Users
avatar

This is a weird one at least for me 7 passed and 1 failed that one was the first one. Any suggestions?


 php artisan test --filter=CompanyGuideTest

   FAIL  Tests\Feature\CompanyGuideTest
   company owner can view his companies guides                                                        0.30s
   company owner cannot view other companies guides                                                   0.03s
   company owner can create guide to his company                                                      0.05s
   company owner cannot create guide to other company                                                 0.03s
   company owner can edit guide for his company                                                       0.03s
   company owner cannot edit guide for other company                                                  0.03s
   company owner can delete guide for his company                                                     0.03s
   company owner cannot delete guide for other company                                                0.03s
  ──────────────────────────────────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CompanyGuideTest > company owner can view his companies guides
  Expected: \n
\n
    \n
        \n
        \n
        \n
\n

To contain: Mr. Albin Hackett PhD

  at tests\Feature\CompanyGuideTest.php:24
     20▕
     21▕         $response = $this->actingAs($user)->get(route('companies.guides.index', $company->id));
     22▕
     23▕         $response->assertOk()
    24▕             ->assertSeeText($secondUser->name);
     25▕     }
     26▕
     27▕     public function test_company_owner_cannot_view_other_companies_guides()
     28▕     {


  Tests:    1 failed, 7 passed (15 assertions)
  Duration: 0.68s



PS the /n goes on for quite a wile like 20 to 30 lines.

avatar

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 ?

avatar

In CompanyGuideController index method

should query the guide role id like this:

$guides = $company->users()->where('role_id', RoleEnum::GUIDE->value)->get();

avatar

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();

    $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.destroy', [$company->id,$guide->id]));


    $response->assertRedirect(route('companies.guides.index', $company->id));

    $this->assertSoftDeleted('users', [
        'name' => $guide->name,
        'email' => $guide->email
    ]);
}


public function test_company_owner_cannot_delete_guides_from_another_company()
{
    $company = Company::factory()->create();
    $companyTwo = Company::factory()->create();

    $user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
    $guide = User::factory()->guide()->create(['company_id' => $companyTwo->id]);

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

    $response->assertForbidden();
}
avatar
You can use Markdown
avatar

In resources/views/companies/guides/edit.blade.php

Undefined variable $user should be $guide

avatar

Updated lesson. Thanks.

avatar
You can use Markdown
avatar
Luis Antonio Parrado

in resources/views/companies/guides/edit.blade.php: line 12 the action route for the form must be companies.guides.update instead of companies.users.update.

avatar

Thank you, well noticed, fixed it!

avatar
You can use Markdown
avatar

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:

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

    $response = $this->actingAs($user)->put(route('companies.guides.update', [$company->id, $user->id]), [
        'name' => 'updated user',
        'email' => '[email protected]',
    ]);

    $response->assertRedirect(route('companies.guides.index', $company->id));

    $this->assertDatabaseHas('users', [
        'name' => 'updated user',
        'email' => '[email protected]',
        'company_id' => $company->id,
    ]);
}

After:

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',
        'email' => '[email protected]',
    ]);

    $response->assertRedirect(route('companies.guides.index', $company->id));

    $this->assertDatabaseHas('users', [
        'name' => 'updated user',
        'email' => '[email protected]',
        'company_id' => $company->id,
    ]);
}

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 by assertSoftDeleted() .

Before:

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

    $response = $this->actingAs($user)->delete(route('companies.guides.update', [$company->id, $user->id]));

    $response->assertRedirect(route('companies.guides.index', $company->id));

    $this->assertDatabaseMissing('users', [
        'name' => 'updated user',
        'email' => '[email protected]',
    ]);
}

After:

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);
}
avatar
You can use Markdown
avatar

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

avatar

It is how route model binding wourks and has nothing to do with tests itself. You can read more about it in the laravel

avatar
You can use Markdown
avatar

StoreGuideRequest is missing use statement for Illuminate\Validation\Rules. This should be explicit for the users that do not use LSP.

avatar
You can use Markdown
avatar
You can use Markdown