0% found this document useful (0 votes)
11 views

Laravel Reservations Course Main

Uploaded by

anonym
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
11 views

Laravel Reservations Course Main

Uploaded by

anonym
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 296

01 1716 words

02 1702 words

03 1041 words

04 2333 words

13 lessons 26614 words July 2023 05 1708 words

Laravel 10
06 2536 words

This content is available only for premium members.


07 4851 words
Or

08 3205 words

09 3332 words

This text-based course is a detailed explanation of how I would 10 1663 words

create a project taken from Upwork: from analyzing the job


description to actually �nishing it off. 11 976 words

12 933 words

13 644 words
I also include a lesson where I mimic the scenario of a client giving

E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


01/13 Client Description to Plan of Action
Our �rst goal, before even starting the coding process, is to transform the
01 1716 words
initial job description from the client into a proper . There are
02 1702 words
many ways to do this, and I will explain my own way.
03 1041 words

04 2333 words

05 1708 words

06 2536 words

Let's take a look at the initial description of the project, based on a 07 4852 words

:
08 3205 words

09 3332 words

10 1663 words

11 976 words

12 933 words

13 644 words
This is how typically clients describe the projects, right? In their own words,
briefly, without digging into details. And that's �ne, they are not specialists.

So the job of a developer, especially a freelancer, is this:

• Transform it into a real plan of action

• While preparing that plan, gather and ask many questions

• Review/edit that plan after getting the answers

Generally, I see my goal as transforming the description into these three


concrete things:

• DB Tables Schema and Models

• List of Features to Build

• Step-by-Step Plan of Actions (a SORTED version of feature list above)

So, let's try to work on exactly that, one by one.

Of course, a lot of things are totally unclear in this simpli�ed job


description, and we don't have the contact with the client to ask, so we will
a lot in the course below.
After re-reading the brief job description, I noted down the list of entities
which is just a fancy word meaning the equivalent of Eloquent Models or
DB Tables.

Also, I immediately try to envision the �elds of the tables, cause that may
lead to follow-up questions to the client

• : just one �eld `name` (I envision roles: "administrator", "company


owner", "customer" and "guide")

• : typical default Laravel DB table, adding `role_id` (we will


manage simple permissions in Gates, no DB table needed)

• : just one �eld `name` - for activity organizers

• : `company_id`, `guide_id`, `name`, `description`,


`start_time`, `price`, `photo(s)`

• : which is actually a pivot table `activity_user` with two


foreign keys
And that's it! So it seems that we will have a very small project, but will
cover all the Laravel basics to make it very practical and learn a lot.

Remember: at this point, your goal is not to build a perfect DB schema from
the beginning, but identify the which would lead to the list of
questions to the client.

What I like to do immediately is to the relationships. There are


different tools for this, but my personal preference is to spin up a Laravel
project, create migrations, run them, and prepare the visual schema from a
real database, instead of drawing and entering DB table/column names in
some external tool.

So...

laravel new reservations


cd reservations
And we have a new Laravel project!

Then, these Terminal commands generate models and migrations:

php artisan make:model Role -m


php artisan make:migration add_role_id_to_users_table

public function up(): void


{
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
:

class Role extends Model


{
use HasFactory;

protected $fillable = ['name'];


}

Schema::table('users', function (Blueprint $table) {


$table->foreignId('role_id')->constrained();
});

class User extends Authenticatable


{
protected $fillable [
'name',
'email',
'password',
'role_id'
];

// ...

public function role()


{
return $this->belongsTo(Role::class);
}
}

Next, creating .

php artisan make:model Company -m


php artisan make:migration add_company_id_to_users_table

:
Schema::create('companies', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});

class Company extends Model


{
use HasFactory;

protected $fillable = ['name'];


}

Schema::table('users', function (Blueprint $table) {


$table->foreignId('company_id')->nullable()->constrained();
});
: in this case, a company may be `nullable`, because only one role
will belong to a company.

class User extends Authenticatable


{
protected $fillable = [
'name',
'email',
'password',
'role_id'
'company_id'
];

// ...

public function company()


{
return $this->belongsTo(Company::class);
}
}
Finally, the schema for .

php artisan make:model Activity -m


php artisan make:migration create_activity_user_table

Schema::create('activities', function (Blueprint $table) {


$table->id();
$table->foreignId('company_id')->constrained();
$table->foreignId('guide_id')->nullable()->constrained('users');
$table->string('name');
$table->text('description');
$table->dateTime('start_time');
$table->integer('price');
$table->string('photo')->nullable();
$table->timestamps();
});

: For now, I don't overcomplicate the photo(s) �eld and plan to save
just the �lename, but this will actually be one of the questions to the client
(we'll get to the questions below). Eventually, we may move to use Spatie
Media Library with its own `media` DB table. The technical solution isn't that
important at this stage.

class Activity extends Model


{
use HasFactory;

protected $fillable = [
'company_id',
'guide_id',
'name',
'description',
'start_time',
'price',
'photo'
];

public function company()


{
return $this->belongsTo(Company::class);
}
public function participants()
{
return $this->belongsToMany(User::class)->withTimestamps();
}
}

Schema::create('activity_user', function (Blueprint $table) {


$table->foreignId('activity_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->timestamps();
});

: I've named the relationship `participants()` and not just


`users()`, for more clarity.

: Pretty sure it will be important to know WHEN someone


registered for the activity, so I added `withTimestamps()` to this pivot table.

And now, we run the migrations on the empty DB table...


php artisan migrate

Now, when we have the DB schema, we can use any external tool to export
it as a visual, with relationships. I personally use for this, which
gives us this result:
Great!

Now, wait, our goal wasn't the DB schema, we planned to create a plan of
action and a list of questions for the client?
Ok, now as our DB entities are clear(er), we can try to list the features our
application will need.

I do that as a "quick braindump", in no particular order, just what comes to


mind, for each role:

• Manage companies and their users

• View / Manage activities

• Manage guides for their companies

• Manage activities and assign guides

• View all activities


• Register for the activity

• View MY activities

• Cancel my reservations

• View activities I'm assigned to

• Export PDF of participants

Looks cool to me. Now, let's try to that list and transform it into a
plan of action.

This is essentially the same as above, but thinking about .

One of the goals is to build the actual "calendar" of tasks for us and to
discover which feature depends on other features to be built earlier.
After some tweaking, I came up with this list, this time:

1. DB structure (done)

2. Laravel Breeze with CRUD of Companies

3. Roles/permissions: Manage companies is only for the admin role (here


we'll start writing tests for each feature)

4. Admin: managing of the company (adding multi-tenancy)

5. Login with company role: manage guides

6. Company role: manage activities and assign guides

7. Sending an email to the guide

8. Company users can also view/manage activities

9. Public homepage with a list of activities

10. Registration to the activity as a customer

11. Login with customer role: view my activities and cancel them

12. Login with guide role: view my activities

13. Guide export PDF

This is, of course, a that may change a lot, but it's something to start
with.

The next step is "thinking exercise": imagine going through those steps in
your mind, and your brain should automatically come up with a bunch of
questions on how exactly things should work. Note them down.

So if I was a freelancer who saw this job post, I would think about a bunch
of things unclear in my head. The best way to clear your mind in such
situations is to, well, just ask the client.

In fact, I have won many freelance pitches for projects just by starting with
a and questions, instead of just telling "I'm a developer with X
years of experience and I can build this project for you".

So, these are just of the questions I would ask:

1. You haven't mentioned anything about visual design: do you have


something in mind? Or would you want to pick from these design
templates? (attaching a link)
2. Would each company have only one user who would manage activities
or multiple? (would cost extra)

3. What information is needed for the activity: title, description, photos,


price? Anything else?

4. Can the guide be assigned to only one company or multiple? How do


they get invited or register on the system?

5. What should the PDF for the guide look like? Do you have any
examples?

6. Do any emails need to be sent automatically? Reminders for


customers/guides?

7. Look, I've prepared this DB schema, does it look accurate to you?


What �elds are missing?

The last one is the most powerful. Yes, the client isn't technical and may not
understand DB design. But it does look partly like a human-friendly
diagram of objects, so they should be able to get the overall idea and
comment on the �elds. Also, they would immediately see that you already
invested signi�cant time in the project (although in reality, it's 1-hour max)
and you are more likely to win this job.

From there, you can start a conversation with the client, discussing that
some extra features would take more time (in the client's mind, cost more
money), in other places you may "cut corners" to deliver faster, etc.

This course is not about freelancing, but I feel I'm talking about that angle
quite a lot here. So I'll get back to the coding part, and if you're interested in
freelance pitches in general, check or
.

Ok, so with this exercise, we have our Plan of Action, which we will follow
(or change a bit) in the upcoming lessons. Let's go build the thing!

Povilas, you tagged this course for July 2023. probably a typo?

Hi, oh someone actually found a new course that I haven't


tweeted/youtubed about yet, well done!

I planned to release it in July originally, but things changed a bit and yeah,
forgot to change the of�cial date in the database. Thanks for noticing!
E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


02/13 Laravel Breeze and Companies CRUD
So now that we have some plan, let's start implementing it. We will start by
01 1716 words
installing starter kit for quick authentication scaffolding and
02 1702 words
a simple layout. Then, we will create the �rst CRUD for .
03 1041 words

04 2333 words

05 1708 words

06 2536 words

So �rst, we will install Breeze. 07 4852 words

08 3205 words

composer require laravel/breeze --dev 09 3332 words

php artisan breeze:install blade


10 1663 words

11 976 words

During the planning phase, we added a `Role` table and a `role_id` 12 933 words

column to the `User` table. Because of this, if you try to register, you will 13 644 words
get an error:

SQLSTATE[HY000]: General error: 1364 Field 'role_id' doesn't have a default value
SQLSTATE[HY000]: General error: 1364 Field 'role_id' doesn't have a default value

So, we need to add the default roles and assign a role when a user
registers.

For adding roles, we will create a .

php artisan make:seeder RoleSeeder

use App\Models\Role;

class RoleSeeder extends Seeder


{
public function run(): void
{
Role::create(['name' => 'administrator']);
Role::create(['name' => 'company owner']);
Role::create(['name' => 'customer']);
Role::create(['name' => 'guide']);
}
}

And add this Seeder to the `DatabaseSeeder`.

class DatabaseSeeder extends Seeder


{
public function run(): void
{
$this->call([
RoleSeeder::class,
]);
}
}

When the user registers, we need to assign a role. We could add the ID of
the `customer` role in the `RegisteredUserController` of Laravel Breeze.
But if a new developer would join this project sometime in the future, he
wouldn't know what that number means. For this, we will use the PHP
Enums feature.
There is no command to create enums, so we will create it manually. First,
create a new directory, `App\Enums`; inside it, create a PHP �le, `Role.php`.
Inside app/Enums/Role.php , we need to add all the roles; their value will be
the ID.

enum Role: int


{
case ADMINISTRATOR = 1;
case COMPANY_OWNER = 2;
case CUSTOMER = 3;
case GUIDE = 4;
}

So now, we can use `Role` Enum where we need it.

use App\Enums\Role;

class RegisteredUserController extends Controller


{
public function store(Request $request): RedirectResponse
{
// ...

$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role_id' => Role::CUSTOMER->value,
]);

// ...
}
}

Now we can register, great!

Ok, now we can move to the actual functionality, and looking at the plan,
we'll start with managing companies.
Next, we can create the companies CRUD. For now, it will be available to
everyone, and in the next lesson, we will restrict this functionality to
administrators only.

In general, my approach: �rst focus on making the feature itself work and
then add more validation and restrictions.

So, �rst, we need a Controller and a Route.

php artisan make:controller CompanyController

use App\Http\Controllers\CompanyController;

Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit

// ...

Route::resource('companies', CompanyController::class);
});

And let's add a navigation link in the menu, just next to the dashboard. We
will copy-paste the existing Laravel Breeze `x-nav-link` component for
that.

// ...
<!-- 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>
<x-nav-link :href="route('companies.index')" :active="request()->routeIs('companies.inde
{{ __('Companies') }}
</x-nav-link>
</div>
// ...
Next, in the Controller, we need to get all the companies and show them.

We will save all Blade �les related to companies in the `resources/views


/companies` directory: it's a common practice to have �les like this, often

corresponding to the Controller methods:

• `resources/views/[feature-name]/index.blade.php`

• `resources/views/[feature-name]/create.blade.php`

• `resources/views/[feature-name]/edit.blade.php`

• etc.

use App\Models\Company;
use Illuminate\View\View;

class CompanyController extends Controller


{
public function index(): View
{
$companies = Company::all();

return view('companies.index', compact('companies'));


}
}

And here's the Blade View �le to show all the companies.

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Companies') }}
</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-whit

<a href="{{ route('companies.create') }}"


class="mb-4 inline-flex items-center rounded-md border border-gray-30
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 tra
</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($companies as $company)
<tr class="bg-white">
<td class="px-6 py-4 text-sm leading-5 text-gray-900
{{ $company->name }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
<a href="{{ route('companies.edit
class="inline-flex items-center rounded-md bo
Edit
</a>
<form action="{{ route('companies.destroy
@csrf
@method('DELETE')
<x-danger-button>
Delete
</x-danger-button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
Now that we can show companies, let's add the and forms.

We will use for the validation. Let's generate them


immediately so we can use them in the Controller.

php artisan make:request StoreCompanyRequest


php artisan make:request UpdateCompanyRequest

Rules in the form request for both save and update are the same.

class StoreCompanyRequest extends FormRequest


{
public function authorize(): bool
{
return true;
}

public function rules(): array


{
return [
'name' => ['required', 'string'],
];
}
}

The Controller code for creating and updating:

use Illuminate\Http\RedirectResponse;
use App\Http\Requests\StoreCompanyRequest;
use App\Http\Requests\UpdateCompanyRequest;

class CompanyController extends Controller


{
// ...

public function create(): View


{
return view('companies.create');
}

public function store(StoreCompanyRequest $request): RedirectResponse


{
Company::create($request->validated());

return to_route('companies.index');
}

public function edit(Company $company)


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

public function update(UpdateCompanyRequest $request, Company $company


{
$company->update($request->validated());

return to_route('companies.index');
}
}

And here are both create and edit forms.

:
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Create Company') }}
</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-whit
<form action="{{ route('companies.store') }}" method="
@csrf

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Edit Company') }}
</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-whit
<form action="{{ route('companies.update', $company) }}
@csrf
@method('PUT')

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
Now we need to implement the method. Not sure if you noticed, but
I've already added the Delete button before, when creating the list page.

All that's left is to add a method to the Controller.

class CompanyController extends Controller


{
// ...

public function destroy(Company $company)


{
$company->delete();

return to_route('companies.index');
}
}
That's it! We have fully working companies CRUD. Let's move on to the next
lesson.

Previous: Client Description to Plan of Next Lesson: Admin Role and


Action Companies

E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


03/13 Admin Role and Companies
After creating the companies CRUD, the next step is to give access to this
01 1716 words
CRUD only for users with the `Administrator` role. For this, we will create a
02 1702 words
.
03 1041 words

04 2333 words

05 1708 words

06 2536 words

So, �rst, we need to create a Middleware and assign a name to it in the 07 4852 words

Kernel �le. We will call it `isAdmin`.


08 3205 words

09 3332 words

php artisan make:middleware IsAdminMiddleware 10 1663 words

11 976 words

: 12 933 words

13 644 words

class Kernel extends HttpKernel


{
// ...
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,

// ...

'isAdmin' => \App\Http\Middleware\IsAdminMiddleware::class,


];
}

We will abort the request in the Middleware if the user doesn't have an
`administrator` role.

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,
return $next($request);
}
}

: I prefer to suf�x all �lenames in Laravel with their purpose, so


`AbcMiddleware` will immediately tell us what that �le does. When naming it

in the Kernel, you can skip this suf�x and shorten it however you want, like
`isAdmin` in my case.

Next, we need to add this Middleware to the companies Route.

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 , not the
administrator.

Next, we must hide `Companies` in the navigation menu for everyone except
the `administrator` role users.

We could create a custom , 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.

// ...
<!-- 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.
{{ __('Companies') }}
</x-nav-link>
@endif
</div>
// ...

So now, other users don't see the `Companies` in the navigation.


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 �nish a certain clear part of that feature.

Some people prefer TDD to write tests �rst, 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 �nal 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 �x 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 scree
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.role

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 �x it. And while we
are at the `UserFactory`, let's add a for easier
`administrator` user creation.

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
'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,
]);
}
}

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 �le of `CompanyTest` will contain all the methods related to managing
companies.

:
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


{
$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 your `phpunit.xml`
to ensure you're working on the database and not live!

• Next, we create a user. In the �rst 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 of
`403 Forbidden`.

Previous: Laravel Breeze and Companies Next Lesson: Admin: Managing


CRUD Users
E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


04/13 Admin: Managing Users
The next step is to add users with the role `company owner`: the ones who
01 1716 words
would later manage reservations and assign guides to them. Before
02 1702 words
implementing this feature, we asked the client a few questions.
03 1041 words
: Can a company have more than one user with the `company
04 2333 words
owner` role? : Yes. : No extra work, the
`belongsTo` relationship is enough, no `many-to-many` here. 05 1708 words

06 2536 words
: Who can manage company owners? Only the `super
administrator` or the company itself. : The company itself. 07 4852 words

: Extra unplanned work. We didn't initially plan to build User 08 3205 words

management for Company Owner roles themselves.


09 3332 words

: Can one "company owner" user belong to more than one 10 1663 words

company? : No. : No extra work, the


11 976 words
`belongsTo` relationship is enough, no `many-to-many` here.
12 933 words

Important at this stage: if we discover new functionality along the way,


13 644 words
the client that some new features will take longer to implement
and/or will cost more.

Ideally, these questions would have been asked before even starting to
code to avoid such misunderstandings.

First, we will implement this User management feature for the users with
the `administrator` role. But instead of doing CRUD for `/users`, we will
immediately divide it by company, so URL will be `/companies/[ID]/users`.

For that, we will use the feature.

So, �rst, let's create a Controller and a Route.

php artisan make:controller CompanyUserController

use App\Http\Controllers\CompanyUserController;
Route::middleware('auth')->group(function () {
// ...

Route::resource('companies', CompanyController::class)->middleware('isAdmin
Route::resource('companies.users', CompanyUserController::class)->except
});

`isAdmin`

`company owner`

So now we can add a new action to the companies list page.

// ...

<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">


<a href="{{ route('companies.users.index', $company) }}"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2
Users
</a>
{{-- ... Edit/Delete buttons --}}
</td>

// ...

Next, in the Controller we need to get all the users that belong to the
company. But �rst, we need a `users` relation in the `Company` Model.

use Illuminate\Database\Eloquent\Relations\HasMany;

class Company extends Model


{
// ...

public function users(): HasMany


{
return $this->hasMany(User::class);
}
}

Similar to how we saved all Blade �les for the companies in the
`resources/views/companies` directory, it's very logical to create a new

directory inside it called `users`. This way when someone would check the
`resources/views/companies` directory he would know that `users` belong

to companies.

So, the directory structure for nested resources would be like this:

• `resources/views/[parent]/[child]/index.blade.php`

• `resources/views/[parent]/[child]/create.blade.php`

• `resources/views/[parent]/[child]/edit.blade.php`

• etc.

:
use App\Models\Company;

class CompanyUserController extends Controller


{
public function index(Company $company)
{
$users = $users = $company->users()->where('role_id', Role::COMPANY_OWNER

return view('companies.users.index', compact('company', 'users'));


}
}

And here's the Blade View �le to show all the users that belong to the
selected company.

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Company users') }}
</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-whit

<a href="{{ route('companies.users.create', $company)


class="mb-4 inline-flex items-center rounded-md border border-gray-30
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 tra
</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($users as $user)
<tr class="bg-white">
<td class="px-6 py-4 text-sm leading-5 text-gray-900
{{ $user->name }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
<a href="{{ route('companies.users.edit
class="inline-flex items-center rounded-md bo
Edit
</a>
<form action="{{ route('companies.users.destroy
@csrf
@method('DELETE')
<x-danger-button>
Delete
</x-danger-button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>

This Blade �le is very similar to the one we had for listing the companies.
The main difference is that because this is a nested View, for every action
we also need to pass the company as a Route parameter.

Now that we can show users for a speci�c company, let's add the
and forms.

For the validation, we will use . Let's generate them


immediately, so we would use them in the Controller.

php artisan make:request StoreUserRequest


php artisan make:request UpdateUserRequest

use App\Models\User;
use Illuminate\Validation\Rules;
class StoreUserRequest 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()],
];
}
}

class UpdateUserRequest extends FormRequest


{
public function authorize(): bool
{
return true;
}

public function rules(): array


{
return [
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email,' . $this
];
}
}

The Controller code for creating and updating:

use App\Enums\Role;
use App\Models\User;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;

class CompanyUserController extends Controller


{
// ...
public function create(Company $company)
{
return view('companies.users.create', compact('company'));
}

public function store(StoreUserRequest $request, Company $company)


{
$company->users()->create([
'name' => $request->input('name'),
'email' => $request->input('email'),
'password' => bcrypt($request->input('password')),
'role_id' => Role::COMPANY_OWNER->value,
]);

return to_route('companies.users.index', $company);


}

public function edit(Company $company, User $user)


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

public function update(UpdateUserRequest $request, Company $company, User


{
$user->update($request->validated());

return to_route('companies.users.index', $company);


}
}

And here are both create and edit forms.

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Create 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-whit
<form action="{{ route('companies.users.store', $company
@csrf

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{
<x-input-error :messages="$errors->get('email')
</div>

<div class="mt-4">
<x-input-label for="password" value="Password"
<x-text-input id="password" name="password" value
<x-input-error :messages="$errors->get('password')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
:

<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-whit
<form action="{{ route('companies.users.update', [$company
@csrf
@method('PUT')

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{
<x-input-error :messages="$errors->get('email')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
When creating the list page we already added the Delete button. All that's
left is to add a method to the Controller.

class CompanyUserController extends Controller


{
// ...
public function destroy(Company $company, User $user)
{
$user->delete();

return to_route('companies.users.index', $company);


}
}

Now let's add tests. The plan is to check that user with the `administrator`
role can perform every CRUD action.

First, we need to create a for the `Company` Model.

php artisan make:factory CompanyFactory

:
class CompanyFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->words(3),
];
}
}

Now the test.

php artisan make:test CompanyUserTest

use App\Models\User;
use App\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CompanyUserTest extends TestCase
{
use RefreshDatabase;

public function test_admin_can_access_company_users_page()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();

$response = $this->actingAs($user)->get(route('companies.users.index

$response->assertOk();
}

public function test_admin_can_create_user_for_a_company()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();

$response = $this->actingAs($user)->post(route('companies.users.store
'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]',
]);
}

public function test_admin_can_edit_user_for_a_company()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create(['company_id' => $company

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

$response->assertRedirect(route('companies.users.index', $company->id

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

public function test_admin_can_delete_user_for_a_company()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create(['company_id' => $company

$response = $this->actingAs($user)->delete(route('companies.users.update

$response->assertRedirect(route('companies.users.index', $company->id

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

In the next lesson, we will expand the tests, after adding the User
management feature to another role.

Previous: Admin Role and Next Lesson: Company Owner: Manages


Companies Users
E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


05/13 Company Owner: Manages Users
Now that the administrator can add users to the company, we need to
01 1716 words
implement a feature where the can add users to the
02 1702 words
company themselves.
03 1041 words

04 2333 words

05 1708 words

06 2536 words

First, let's add `SoftDeletes` for the User Model if someone accidentally 07 4852 words

deletes a user. I personally do that for almost all DB tables in Laravel, my


08 3205 words
experience showed that such "just in case" paid off in case of emergencies
09 3332 words
too often.
10 1663 words

11 976 words
php artisan make:migration "add soft deletes to users table"
12 933 words

13 644 words

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

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 �x it.
:

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 �xed!

> 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

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

: 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`.

// ...
<!-- 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.
{{ __('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)
{{ __('Administrators') }}
</x-nav-link>
@endif
</div>
// ...
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, �rst, let's create a and register it in the `AuthServiceProvider`.

php artisan make:policy CompanyUserPolicy --model=Company

use App\Models\Company;
use App\Policies\CompanyUserPolicy;

class AuthServiceProvider extends ServiceProvider


{
protected $policies = [
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 . So, I


remembered the `before` method. In this method, we will just
return `true` if the user has the role of `administrator`.

So, the whole policy code is below.

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
}
public function create(User $user, Company $company): bool
{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id
}

public function update(User $user, Company $company): bool


{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id
}

public function delete(User $user, Company $company): bool


{
return $user->role_id === Role::COMPANY_OWNER->value && $user->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.

:
class CompanyUserController extends Controller
{
public function index(Company $company)
{
$this->authorize('viewAny', $company);

// ...
}

public function create(Company $company)


{
$this->authorize('create', $company);

// ...
}

public function store(StoreUserRequest $request, Company $company)


{
$this->authorize('create', $company);

// ...
}

public function edit(Company $company, User $user)


{
$this->authorize('update', $company);
// ...
}

public function update(UpdateUserRequest $request, Company $company, User


{
$this->authorize('update', $company);

// ...
}

public function destroy(Company $company, User $user)


{
$this->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.
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 for the
`Company Owner` role.

class UserFactory extends Factory


{
// ...

public function companyOwner(): static


{
return $this->state(fn (array $attributes) => [
'role_id' => Role::COMPANY_OWNER->value,
]);
}
}

And the tests themselves.

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
$secondUser = User::factory()->companyOwner()->create(['company_id

$response = $this->actingAs($user)->get(route('companies.users.index

$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

$response = $this->actingAs($user)->get(route('companies.users.index

$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

$response = $this->actingAs($user)->post(route('companies.users.store
'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

$response = $this->actingAs($user)->post(route('companies.users.store
'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

$response = $this->actingAs($user)->put(route('companies.users.update
'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

$response = $this->actingAs($user)->put(route('companies.users.update
'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

$response = $this->actingAs($user)->delete(route('companies.users.update

$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

$response = $this->actingAs($user)->delete(route('companies.users.update

$response->assertForbidden();
}
}

Good. All the tests passed!

Previous: Admin: Managing Users Next Lesson: Managing Guides


E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


06/13 Managing Guides
Before implementing the creation of the users with the role of `Guide`, we
01 1716 words
asked the client one question again.
02 1702 words

: Who will be able to manage guides? Only company owners? Or


03 1041 words
would administrators need this feature, too? : No, only company
04 2333 words
owners will manage guides for their company. : No
additional changes to the structure are needed. 05 1708 words

06 2536 words
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, 07 4852 words

just with a different role. 08 3205 words

This is how projects are usually created: you uncover feature after feature, 09 3332 words

looking back to see if you can reuse previous functionality or need to 10 1663 words

perform some code refactoring with each "new layer".


11 976 words

12 933 words

13 644 words

So, as always, we need a Controller and to add Routes. Again this Controller
will be a .

php artisan make:controller CompanyGuideController

use App\Http\Controllers\CompanyGuideController;

Route::middleware('auth')->group(function () {
// ...

Route::resource('companies.users', CompanyUserController::class)->except
Route::resource('companies.guides', CompanyGuideController::class)->except
});

For the navigation, we will add it after the `Administrators` link in the same
`if` statement.

:
// ...
<!-- 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.
{{ __('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)
{{ __('Administrators') }}
</x-nav-link>
<x-nav-link :href="route('companies.guides.index', auth()->user()->company_id)
{{ __('Guides') }}
</x-nav-link>
@endif
</div>
// ...
For the validation, we will again use the .

php artisan make:request StoreGuideRequest


php artisan make:request UpdateGuideRequest

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()],
];
}
}

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
];
}
}

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

use App\Enums\Role;
use App\Models\User;
use App\Models\Company;
use App\Http\Requests\StoreGuideRequest;
use App\Http\Requests\UpdateGuideRequest;

class CompanyGuideController extends Controller


{
public function index(Company $company)
{
$this->authorize('viewAny', $company);

$guides = $company->users()->where('role_id', Role::COMPANY_OWNER->value

return view('companies.guides.index', compact('company', 'guides'));


}

public function create(Company $company)


{
$this->authorize('create', $company);
return view('companies.guides.create', compact('company'));
}

public function store(StoreGuideRequest $request, Company $company)


{
$this->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)


{
$this->authorize('update', $company);

return view('companies.guides.edit', compact('company', 'guide'));


}

public function update(UpdateGuideRequest $request, Company $company,


{
$this->authorize('update', $company);
$guide->update($request->validated());

return to_route('companies.guides.index', $company);


}

public function destroy(Company $company, User $guide)


{
$this->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 �les will be saved in the `resources/views/companies
/guides` directory. Here are the Blade �les for listing, creating, and editing

guides.

<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-whit

<a href="{{ route('companies.guides.create', $company)


class="mb-4 inline-flex items-center rounded-md border border-gray-30
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 tra
</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
{{ $guide->name }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
<a href="{{ route('companies.guides.edit
class="inline-flex items-center rounded-md bo
Edit
</a>
<form action="{{ route('companies.guides.destroy
@csrf
@method('DELETE')
<x-danger-button>
Delete
</x-danger-button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>

<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-whit
<form action="{{ route('companies.guides.store', $company
@csrf

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>
<div class="mt-4">
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{
<x-input-error :messages="$errors->get('email')
</div>

<div class="mt-4">
<x-input-label for="password" value="Password"
<x-text-input id="password" name="password" value
<x-input-error :messages="$errors->get('password')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>

:
<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-whit
<form action="{{ route('companies.users.update', [$company
@csrf
@method('PUT')

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{
<x-input-error :messages="$errors->get('email')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
Before adding the tests, again, we need to add another for
the `Guide` role.

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.

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
$secondUser = User::factory()->guide()->create(['company_id' => $company

$response = $this->actingAs($user)->get(route('companies.guides.index

$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

$response = $this->actingAs($user)->get(route('companies.guides.index

$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

$response = $this->actingAs($user)->post(route('companies.guides.store
'name' => 'test user',
'email' => '[email protected]',
password => password ,
]);

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

$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

$response = $this->actingAs($user)->post(route('companies.guides.store
'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

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

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

$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

$response = $this->actingAs($user)->put(route('companies.guides.update
'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

$response = $this->actingAs($user)->delete(route('companies.guides.update

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

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

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

$response = $this->actingAs($user)->delete(route('companies.guides.update
$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 speci�c situation: in this case, the code parts
are identical . But there's a big possibility that Guides will have their
own extra �elds 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 Next Lesson: Manage Activities and Assign
Users Guides
E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


07/13 Manage Activities and Assign Guides
In this lesson, let's build a new feature for managing and
01 1716 words
to them.
02 1702 words

Here's the list of topics that we'll cover below:


03 1041 words

• Will create the CRUD for activities. 04 2333 words

• Will make it work for both `company owner` and `administrator` users. 05 1708 words

• Will add authorization for activities using Policies. 06 2536 words

• Will write tests. 07 4852 words

08 3205 words

09 3332 words

10 1663 words

11 976 words

Again, we can only create CRUD with Controller and Routes. The Controller 12 933 words

here will also be nested so that we will have the URLs like `/companies
13 644 words
/1/activities`.

php artisan make:controller CompanyActivityController


:

use App\Http\Controllers\CompanyActivityController;

Route::middleware('auth')->group(function () {
// ...

Route::resource('companies.activities', CompanyActivityController::class
});

: this is not the �rst CRUD where we use Nested Controllers, but it's
not the only way. In this case, it's just my personal preference to implement
multi-tenancy this way, to access records only with their company ID in the
URL. You may build CRUDs differently, with URLs like `/activities`,
`/guides`, and others, and check `company_id` in another way, like using

with the `auth()->user()->company_id` value.

As for the navigation, we will add the `Activities` link after the `Guides`.

:
// ...
<!-- 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.
{{ __('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)
{{ __('Administrators') }}
</x-nav-link>
<x-nav-link :href="route('companies.guides.index', auth()->user()->company_id)
{{ __('Guides') }}
</x-nav-link>
<x-nav-link :href="route('companies.activities.index', auth()->user()->company_id)
{{ __('Activities') }}
</x-nav-link>
@endif
</div>
// ...
For the validation, again, the .

php artisan make:request StoreActivityRequest


php artisan make:request UpdateActivityRequest

class StoreActivityRequest extends FormRequest


{
public function authorize(): bool
{
return true;
}

public function rules(): array


{
return [
'name' => ['required'],
'description' => ['required'],
'start_time' => ['required', 'date'],
'price' => ['required', 'numeric'],
'image' => ['image', 'nullable'],
'guide_id' => ['required', 'exists:users,id'],
];
}
}

class UpdateActivityRequest extends FormRequest


{
public function authorize(): bool
{
return true;
}

public function rules(): array


{
return [
'name' => ['required'],
'description' => ['required'],
'start_time' => ['required', 'date'],
'price' => ['required', 'numeric'],
'image' => ['image', 'nullable'],
'guide_id' => ['required', 'exists:users,id'],
];
}
}

Now we can add code to the Controller, show the list of activities, and
create and edit forms.

For storing Blade �les, we will use the same structure as in the past lessons,
the `resources/views/companies/activities` directory.

For the photo, for now, it will be just a simple upload stored on a public
disk, without any more complex �le manipulations or external packages.

use App\Enums\Role;
use App\Models\User;
use App\Models\Company;
use App\Models\Activity;
use Illuminate\Support\Facades\Storage;
use App\Http\Requests\StoreActivityRequest;
use App\Http\Requests\UpdateActivityRequest;

class CompanyActivityController extends Controller


{
public function index(Company $company)
{
$company->load('activities');

return view('companies.activities.index', compact('company'));


}

public function create(Company $company)


{
$guides = User::where('company_id', $company->id)
->where('role_id', Role::GUIDE->value)
->pluck('name', 'id');

return view('companies.activities.create', compact('guides', 'company


}

public function store(StoreActivityRequest $request, Company $company)


{
if ($request->hasFile('image')) {
$path = $request->file('image')->store('activities', 'public');
}
$activity = Activity::create($request->validated() + [
'company_id' => $company->id,
'photo' => $path ?? null,
]);

return to_route('companies.activities.index', $company);


}

public function edit(Company $company, Activity $activity)


{
$this->authorize('update', $company);

$guides = User::where('company_id', $company->id)


->where('role_id', Role::GUIDE->value)
->pluck('name', 'id');

return view('companies.activities.edit', compact('guides', 'activity


}

public function update(UpdateActivityRequest $request, Company $company


{
if ($request->hasFile('image')) {
$path = $request->file('image')->store('activities', 'public');
if ($activity->photo) {
Storage::disk('public')->delete($activity->photo);
}
}
$activity->update($request->validated() + [
'photo' => $path ?? $activity->photo,
]);

return to_route('companies.activities.index', $company);


}

public function destroy(Company $company, Activity $activity)


{
$activity->delete();

return to_route('companies.activities.index', $company);


}
}

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Activities') }}
</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-whit

<a href="{{ route('companies.activities.create', $company


class="mb-4 inline-flex items-center rounded-md border border-gray-30
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 tra
</th>
<th class="bg-gray-50 px-6 py-3 text-left"
<span class="text-xs font-medium uppercase leading-4 tra
</th>
<th class="bg-gray-50 px-6 py-3 text-left"
<span class="text-xs font-medium uppercase leading-4 tra
</th>
<th class="bg-gray-50 px-6 py-3 text-left"
<span class="text-xs font-medium uppercase leading-4 tra
</th>
<th class="w-96 bg-gray-50 px-6 py-3 text-left
</th>
</tr>
</thead>

<tbody class="bg-white divide-y divide-gray-200 divide-solid


@foreach($company->activities as $activity
<tr class="bg-white">
<td class="px-6 py-4 text-sm leading-5 text-gray-900
@if($activity->photo)
<img src="{{ asset($activity
@endif
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
{{ $activity->name }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
{{ $activity->start_time }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
{{ $activity->price }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900
<a href="{{ route('companies.activities.edit
class="inline-flex items-center rounded-md bo
Edit
</a>
<form action="{{ route('companies.activities.des
@csrf
@method('DELETE')
<x-danger-button>
Delete
</x-danger-button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
Because we are saving the price in cents, before adding value to the DB,
we need to multiply it by 100. And the other way around: to show the
correct value in the front end, we need to divide by 100. We will use
for this.

use Illuminate\Database\Eloquent\Casts\Attribute;

class Activity extends Model


{
// ...

public function price(): Attribute


{
return Attribute::make(
get: fn($value) => $value / 100,
set: fn($value) => $value * 100,
);
}
}
:

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Create Activity') }}
</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-whit
<form action="{{ route('companies.activities.store', $
@csrf

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-input-label for="description" value="Description
<textarea id="description" name="description"
<x-input-error :messages="$errors->get('description')
</div>

<div class="mt-4">
<x-input-label for="start_time" value="Start time
<x-text-input id="start_time" name="start_time
<x-input-error :messages="$errors->get('start_time')
</div>

<div class="mt-4">
<x-input-label for="price" value="Price" />
<x-text-input id="price" name="price" value="{{
<x-input-error :messages="$errors->get('price')
</div>

<div class="mt-4">
<x-input-label for="image" value="Photo" />
<x-text-input id="image" name="image" type="file
<x-input-error :messages="$errors->get('image')
</div>

<div class="mt-4">
<x-input-label for="guide_id" value="Guides" />
<select name="guide_id" id="guide_id" class="mt-1 block w-full r
<option>-- SELECT GUIDE --</option>
@foreach($guides as $id => $name)
<option value="{{ $id }}" @selected(old
@endforeach
</select>
<x-input-error :messages="$errors->get('guide_id')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
:

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Edit Activity') }}
</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-whit
<form action="{{ route('companies.activities.update',
@csrf
@method('PUT')

<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{
<x-input-error :messages="$errors->get('name')
</div>

<div class="mt-4">
<x-input-label for="description" value="Description
<textarea id="description" name="description"
<x-input-error :messages="$errors->get('description')
</div>

<div class="mt-4">
<x-input-label for="start_time" value="Start time
<x-text-input id="start_time" name="start_time
<x-input-error :messages="$errors->get('start_time')
</div>

<div class="mt-4">
<x-input-label for="price" value="Price" />
<x-text-input id="price" name="price" value="{{
<x-input-error :messages="$errors->get('price')
</div>

<div class="mt-4">
@if($activity->photo)
<img src="{{ asset($activity->photo) }}" alt
@endif

<x-input-label for="image" value="Photo" />


<x-text-input id="image" name="image" type="file
<x-input-error :messages="$errors->get('image')
</div>

<div class="mt-4">
<x-input-label for="guide_id" value="Guides" />
<select name="guide_id" id="guide_id" class="mt-1 block w-full r
<option>-- SELECT GUIDE --</option>
@foreach($guides as $id => $name)
<option value="{{ $id }}" @selected(old
@endforeach
</select>
<x-input-error :messages="$errors->get('guide_id')
</div>

<div class="mt-4">
<x-primary-button>
Save
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
Of course, only hiding the navigation isn't secure enough. To keep
consistency, we will use Policies. Let's create a Policy and register it.

php artisan make:policy CompanyActivityPolicy --model=Activity

use App\Models\Activity;
use App\Policies\ActivityPolicy;

class AuthServiceProvider extends ServiceProvider


{
protected $policies = [
Company::class => CompanyUserPolicy::class,
Activity::class => CompanyActivityPolicy::class,
];
// ...
}

In the Policy, we will check if the user has the `Company Owner` role and is
doing the action for his company.

But for the `administrator` role, we just need to allow as we did


in the `CompanyUserPolicy`. So, we will use the `before`
method again. In this method, we will return `true` if the user has the role
of `administrator`.

use App\Enums\Role;
use App\Models\Company;
use App\Models\Activity;
use App\Models\User;

class CompanyActivityPolicy
{
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
}

public function create(User $user, Company $company): bool


{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id
}

public function update(User $user, Activity $activity): bool


{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id
}

public function delete(User $user, Activity $activity): bool


{
return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id
}
}
Next, we need to use this Policy in the Controller. Because here we are
using a simple Resource Controller, we can use the
method in the Controller's constructor.

Next, in the `CompanyActivityController`, we must do the `authorize`


check for each CRUD action. Again, there are a couple of ways to do that,
but I will use the `authorize` method for consistency.

class CompanyActivityController extends Controller


{
public function index(Company $company)
{
$this->authorize('viewAny', $company);

// ...
}

public function create(Company $company)


{
$this->authorize('create', $company);
// ...
}

public function store(StoreActivityRequest $request, Company $company)


{
$this->authorize('create', $company);

// ...
}

public function edit(Company $company, Activity $activity)


{
$this->authorize('update', $company);

// ...
}

public function update(UpdateActivityRequest $request, Company $company


{
$this->authorize('update', $company);

// ...
}

public function destroy(Company $company, Activity $activity)


{
$this->authorize('delete', $company);
// ...
}
}

For showing the activities of every company for the `administrator` user,
we already have made all the logic. We just need to add a link to the
Companies list so administrators can access that page.

// ...
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
<a href="{{ route('companies.users.index', $company) }}"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2
Users
</a>
<a href= {{ route( companies.activities.index , $company) }}
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2
Activities
</a>

{{-- ... Edit/Delete buttons --}}


</td>
// ...

As with every feature, we add tests, no exception here. First, because we


are working with the `Activity` Model, we need to create a Factory for it.

php artisan make:factory ActivityFactory

:
use App\Models\Company;
use Illuminate\Support\Carbon;

class ActivityFactory extends Factory


{
public function definition(): array
{
return [
'company_id' => Company::factory(),
'guide_id' => User::factory()->guide(),
'name' => fake()->name(),
'description' => fake()->text(),
'start_time' => Carbon::now(),
'price' => fake()->randomNumber(5),
];
}
}

Now the test.

php artisan make:test CompanyActivityTest

So, what do we need to test here? Permissions, probably: the `Company


Owner` needs to perform every action only for their company.

So, we need to test the following:

• For the activities list: test that the company owner can see only their
company's activities and cannot see other companies.

• For create, edit, and delete: it's the same, and we have already written
similar tests in the `CompanyGuideTest`.

• In the create and edit, the list of the guides should be visible only from
that company.

• Image upload shouldn't allow uploading non-image �les.

• Administrator user can perform every CRUD action.

use Tests\TestCase;
use App\Models\User;
use App\Models\Company;
use App\Models\Activity;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;

class CompanyActivityTest extends TestCase


{
use RefreshDatabase;

public function test_company_owner_can_view_activities_page()


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company

$response = $this->actingAs($user)->get(route('companies.activities.index

$response->assertOk();
}

public function test_company_owner_can_see_only_his_companies_activities


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$activity = Activity::factory()->create(['company_id' => $company->id
$activity2 = Activity::factory()->create();

$response = $this->actingAs($user)->get(route('companies.activities.index

$response->assertSeeText($activity->name)
->assertDontSeeText($activity2->name);
}

public function test_company_owner_can_create_activity()


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$guide = User::factory()->guide()->create();

$response = $this->actingAs($user)->post(route('companies.activities.store
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guide_id' => $guide->id,
]);

$response->assertRedirect(route('companies.activities.index', $company

$this->assertDatabaseHas('activities', [
'company_id' => $company->id,
'guide_id' => $guide->id,
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 999900,
]);
}
public function test_can_upload_image()
{
Storage::fake('activities');

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

$file = UploadedFile::fake()->image('avatar.jpg');

$this->actingAs($user)->post(route('companies.activities.store', $
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guide_id' => $guide->id,
'image' => $file,
]);

Storage::disk('activities')->assertExists($file->hashName());
Storage::disk('activities')->assertExists('thumbs/' . $file->hashName
}

public function test_cannon_upload_non_image_file()


{
Storage::fake('activities');
$company Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$guide = User::factory()->guide()->create();

$file = UploadedFile::fake()->create('document.pdf', 2000, 'application/pdf

$response = $this->actingAs($user)->post(route('companies.activities.store
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guides' => $guide->id,
'image' => $file,
]);

$response->assertSessionHasErrors(['image']);

Storage::disk('activities')->assertMissing($file->hashName());
}

public function test_guides_are_shown_only_for_specific_company_in_create_form


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$guide = User::factory()->guide()->create(['company_id' => $company

$company2 = Company::factory()->create();
$guide2 = User::factory()->guide()->create(['company_id' => $company2
$response = $this->actingAs($user)->get(route('companies.activities.create

$response->assertViewHas('guides', function (Collection $guides) use


return $guide->name === $guides[$guide->id];
});

$response->assertViewHas('guides', function (Collection $guides) use


return ! array_key_exists($guide2->id, $guides->toArray());
});
}

public function test_guides_are_shown_only_for_specific_company_in_edit_form


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$guide = User::factory()->guide()->create(['company_id' => $company
$activity = Activity::factory()->create(['company_id' => $company->id

$company2 = Company::factory()->create();
$guide2 = User::factory()->guide()->create(['company_id' => $company2

$response = $this->actingAs($user)->get(route('companies.activities.edit

$response->assertViewHas('guides', function (Collection $guides) use


return $guide->name === $guides[$guide->id];
});
$response->assertViewHas('guides', function (Collection $guides) use
return ! array_key_exists($guide2->id, $guides->toArray());
});
}

public function test_company_owner_can_edit_activity()


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$guide = User::factory()->guide()->create();
$activity = Activity::factory()->create(['company_id' => $company->id

$response = $this->actingAs($user)->put(route('companies.activities.update
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guide_id' => $guide->id,
]);

$response->assertRedirect(route('companies.activities.index', $company

$this->assertDatabaseHas('activities', [
'company_id' => $company->id,
'guide_id' => $guide->id,
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 999900,
]);
}

public function test_company_owner_cannot_edit_activity_for_other_company


{
$company = Company::factory()->create();
$company2 = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$guide = User::factory()->guide()->create();
$activity = Activity::factory()->create(['company_id' => $company2

$response = $this->actingAs($user)->put(route('companies.activities.update
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guide_id' => $guide->id,
]);

$response->assertForbidden();
}

public function test_company_owner_can_delete_activity()


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$activity = Activity::factory()->create(['company_id' => $company->id

$response = $this->actingAs($user)->delete(route('companies.activities.destroy

$response->assertRedirect(route('companies.activities.index', $company

$this->assertModelMissing($activity);
}

public function test_company_owner_cannot_delete_activity_for_other_company


{
$company = Company::factory()->create();
$company2 = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company
$activity = Activity::factory()->create(['company_id' => $company2

$response = $this->actingAs($user)->delete(route('companies.activities.destroy

$this->assertModelExists($activity);
$response->assertForbidden();
}

public function test_admin_can_view_companies_activities()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
$response = $this->actingAs($user)->get(route( companies.activities.index

$response->assertOk();
}

public function test_admin_can_create_activity_for_company()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
$guide = User::factory()->guide()->create();

$response = $this->actingAs($user)->post(route('companies.activities.store
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guide_id' => $guide->id,
]);

$response->assertRedirect(route('companies.activities.index', $company

$this->assertDatabaseHas('activities', [
'company_id' => $company->id,
'guide_id' => $guide->id,
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 999900,
]);
}

public function test_admin_can_edit_activity_for_company()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
$guide = User::factory()->guide()->create();
$activity = Activity::factory()->create(['company_id' => $company->id

$response = $this->actingAs($user)->put(route('companies.activities.update
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guide_id' => $guide->id,
]);

$response->assertRedirect(route('companies.activities.index', $company

$this->assertDatabaseHas('activities', [
'company_id' => $company->id,
'guide_id' => $guide->id,
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 999900,
]);
}
}

I think we created enough functionality to show it to the client and see what
they say so far. Such a feedback loop is very important: the earlier we learn
about the changes, the less code refactoring we need.

In the next lesson, we will make changes according to the client's feedback.

Previous: Managing Guides Next Lesson: After the First Client Review
E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


08/13 After the First Client Review
After making MVP in the last lessons, we sent the result for a client review.
01 1716 words
Here's one point of feedback they gave us:
02 1702 words

03 1041 words

04 2333 words

05 1708 words

06 2536 words

07 4852 words
Good point. So... It seems like we did not discuss the user creation flow
08 3205 words
enough, so now we need to make changes (the lesson below) and discuss
potential delays/costs with the client. Lesson learned for next time - discuss 09 3332 words

how features should work in more detail upfront.


10 1663 words

Here's the list of topics that we'll cover below: 11 976 words

12 933 words
• Sending an email to the invited user.
13 644 words
• Use the invitation link to register the user to the correct company and
role.

• Rewriting old tests and writing new ones.


So, �rst, instead of creating the user right away, let's send an invitation
email. They won't be the system users until they register with their
password.

So, we will create a separate Model called `UserInvitation` where we will


keep the email, invitation token, company ID, and role ID.

php artisan make:model UserInvitation -m

public function up(): void


{
Schema::create('user_invitations', function (Blueprint $table) {
$table->increments('id');
$table->string('email')->unique();
$table->string('token', 36)->unique()->nullable();
$table->timestamp('registered_at')->nullable();
$table->foreignId('company_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
}

class UserInvitation extends Model


{
protected $fillable = [
'email',
'token',
'registered_at',
'company_id',
'role_id',
];
}

Next, we must change the form for `Company Owner` and `Guides` CRUD.
We won't need the name and passwords.

//
@csrf

<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block m
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>

<div class="mt-4"> {{-- }}


<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="bloc
<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
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}
<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

//
@csrf

<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block m
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>

<div class="mt-4"> {{-- }}


<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="bloc
<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
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}

<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

//
@csrf

<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block m
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>

<div class="mt-4"> {{-- }}


<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="bloc
<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
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}

<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

:
//
@csrf

<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block m
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>

<div class="mt-4"> {{-- }}


<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="bloc
<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
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}

<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

Also, it means we need only the email in the Form Request. The email must
also check uniqueness in the `user_invitations` table instead of the
`users` table. And we will change the validation message.

class StoreGuideRequest extends FormRequest


{
// ...
public function rules(): array
{
return [
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Rules\Password::defaults()],
'email' => ['required', 'email', 'unique:user_invitations,email
];
}

public function messages(): array


{
return [
'email.unique' => 'Invitation with this email address already requested.
];
}
}

class StoreGuideRequest extends FormRequest


{
// ...

public function rules(): array


{
return [
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Rules\Password::defaults()],
'email' => ['required', 'email', 'unique:user_invitations,email
];
}

public function messages(): array


{
return [
'email.unique' => 'Invitation with this email address already requested.
];
}
}

Now, we need to create the .

php artisan make:mail RegistrationInvite --markdown=emails.invitation

The Mail will accept the invitation, which we will create in the Controller
later before sending the Mail. And to the markdown, we need to pass the
invitation URL.

use App\Models\UserInvitation;

class RegistrationInvite extends Mailable


{
use Queueable, SerializesModels;

public function __construct(private readonly UserInvitation $invitation


{}

public function envelope(): Envelope


{
return new Envelope(
subject: 'Invitation',
);
}

public function content(): Content


{
return new Content(
markdown: 'emails.invitation',
with: [
'inviteUrl' => urldecode(route('register') . '?invitation_token=
]
);
}
}

And the Mail message can look like this.

<x-mail::message>
# You Have Been Invited

You have been invited to the {{ config('app.name') }}

<x-mail::button :url="$inviteUrl">
Register
</x-mail::button>

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

Next, in the Controller, instead of creating a user, we send the invitation.


:

use App\Models\UserInvitation;
use Illuminate\Support\Str;
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;

class CompanyUserController extends Controller


{
// ...

public function store(StoreUserRequest $request, Company $company)


{
$this->authorize('create', $company);

$invitation = UserInvitation::create([
'email' => $request->input('email'),
'token' => Str::uuid(),
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);

Mail::to($request->input('email'))->send(new UserRegistrationInvite

return to_route('companies.users.index', $company);


}

// ...
}

use App\Models\UserInvitation;
use Illuminate\Support\Str;
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;

class CompanyGuideController extends Controller


{
// ...

public function store(StoreGuideRequest $request, Company $company)


{
$this->authorize('create', $company);

$invitation = UserInvitation::create([
'email' => $request->input('email'),
'token' => Str::uuid(),
'company_id' => $company->id,
'role_id' => Role::GUIDE->value,
]);

Mail::to($request->input('email'))->send(new UserRegistrationInvite

return to_route('companies.guides.index', $company);


}

// ...
}

And the invitation email is now sent.


Now that users can receive the invitation email, we need to make the
registration part work. This can be done in a couple of ways, like adding a
hidden �eld with the token, but I chose to do it with .

First, in the `RegisteredUserController` of Laravel Breeze, let's put the


token into a Session and auto-�ll the email �eld.

use App\Models\UserInvitation;

class RegisteredUserController extends Controller


{
public function create(Request $request): View
{
$email = null;

if ($request->has('invitation_token')) {
$token = $request->input('invitation_token');

session()->put('invitation_token', $token);

$invitation = UserInvitation::where('token', $token)


->whereNull('registered_at')
->firstOrFail();

$email = $invitation->email;
}

return view('auth.register', compact('email'));


}

// ...
}

:
// ...
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="
<x-text-input id="email" class="block mt-1 w-full" type="email" name="
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
// ...
All that's left is to check if the Session has the `invitation_token` key. If it
does, get the invitation where the token and entered email match, and
`registered_at` is null.

And then, when creating the user, set the correct `company_id` and
`role_id`.

use Illuminate\Validation\ValidationException;

class RegisteredUserController extends Controller


{
// ...
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:
'password' => ['required', 'confirmed', Rules\Password::defaults
]);

if ($request->session()->get('invitation_token')) {
$invitation = UserInvitation::where('token', $request->session
->where('email', $request->email)
->whereNull('registered_at')
->firstOr(fn() => throw ValidationException::withMessages([

$role = $invitation->role_id;
$company = $invitation->company_id;

$invitation->update(['registered_at' => now()]);


}

$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role_id' => Role::CUSTOMER->value,
'role_id' => $role ?? Role::CUSTOMER->value,
'company_id' => $company ?? null,
]);

event(new Registered($user));

Auth::login($user);
return redirect(RouteServiceProvider::HOME);
}
}

If we don't �nd the invitation, we throw the Validation Exception. Let's show
the validation message at the start of the registration form.

<x-guest-layout>
<form method="POST" action="{{ route('register') }}">
@csrf

<x-input-error :messages="$errors->get('invitation')" class="mt-2"

// ...
After changing the user creation logic, we must also change the tests and
add a new one for the registration. But �rst, let's add a setting in the main
`TestCase` that our Seeders would always be executed.

:
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;

protected bool $seed = true;


}

Now for the tests. Instead of testing that user was created, we need to
check that the invitation was created with the right `company_id` and
`role_id` and that the Mail was sent.

We will create new tests in the `CompanyUserTest` instead of the old


`test_admin_can_create_user_for_a_company` and

`test_company_owner_can_create_user_to_his_company`.

use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;

class CompanyUserTest extends TestCase


{
use RefreshDatabase;
// ...

public function test_admin_can_send_invite_to_user_for_a_company()


{
Mail::fake();

$company = Company::factory()->create();
$user = User::factory()->admin()->create();

$response = $this->actingAs($user)->post(route('companies.users.store
'email' => '[email protected]',
]);

Mail::assertSent(UserRegistrationInvite::class);

$response->assertRedirect(route('companies.users.index', $company->id

$this->assertDatabaseHas('user_invitations', [
'email' => '[email protected]',
'registered_at' => null,
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);
}

public function test_invitation_can_be_sent_only_once_for_user()


{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();

$this->actingAs($user)->post(route('companies.users.store', $company
'email' => '[email protected]',
]);

$response = $this->actingAs($user)->post(route('companies.users.store
'email' => '[email protected]',
]);

$response->assertInvalid(['email' => 'Invitation with this email address already req


}

// ...

public function test_company_owner_can_send_invite_to_user()


{
Mail::fake();

$company = Company::factory()->create();
$user = User::factory()->admin()->create();

$response = $this->actingAs($user)->post(route('companies.users.store
'email' => '[email protected]',
]);
Mail::assertSent(UserRegistrationInvite::class);

$response->assertRedirect(route('companies.users.index', $company->id

$this->assertDatabaseHas('user_invitations', [
'email' => '[email protected]',
'registered_at' => null,
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);
}

// ...
}

The same goes for the `CompanyGuideTest` test, a new one instead of
`test_company_owner_can_create_guide_to_his_company`.

use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;

class CompanyGuideTest extends TestCase


{
use RefreshDatabase;

// ...

public function test_company_owner_can_send_invite_to_guide_to_his_company


{
Mail::fake();

$company = Company::factory()->create();
$user = User::factory()->admin()->create();

$response = $this->actingAs($user)->post(route('companies.guides.store
'email' => '[email protected]',
]);

Mail::assertSent(UserRegistrationInvite::class);

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

$this->assertDatabaseHas('user_invitations', [
'email' => '[email protected]',
'registered_at' => null,
'company_id' => $company->id,
'role_id' => Role::GUIDE->value,
]);
}
public function test_invitation_can_be_sent_only_once_for_user()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company

$this->actingAs($user)->post(route('companies.guides.store', $company
'email' => '[email protected]',
]);

$response = $this->actingAs($user)->post(route('companies.guides.store
'email' => '[email protected]',
]);

$response->assertInvalid(['email' => 'Invitation with this email address already req


}

// ...
}

That's it for the invitation tests. Now let's add new tests for the registration.
We will test that the user is registered with the right company and get the
correct role.

:
use App\Enums\Role;
use App\Models\User;
use App\Models\Company;
use App\Models\UserInvitation;
use Illuminate\Support\Facades\Auth;

class RegistrationTest extends TestCase


{
// ...

public function test_user_can_register_with_token_for_company_owner_role


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company

$this->actingAs($user)->post(route('companies.users.store', $company
'email' => '[email protected]',
]);

$invitation = UserInvitation::where('email', '[email protected]')->first

Auth::logout();

$response = $this->withSession(['invitation_token' => $invitation->token


'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);

$this->assertDatabaseHas('users', [
'name' => 'Test User',
'email' => '[email protected]',
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);

$this->assertAuthenticated();

$response->assertRedirect(RouteServiceProvider::HOME);
}

public function test_user_can_register_with_token_for_guide_role()


{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company

$this->actingAs($user)->post(route('companies.guides.store', $company
'email' => '[email protected]',
]);

$invitation = UserInvitation::where('email', '[email protected]')->first


Auth::logout();

$response = $this->withSession(['invitation_token' => $invitation->token


'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);

$this->assertDatabaseHas('users', [
'name' => 'Test User',
'email' => '[email protected]',
'company_id' => $company->id,
'role_id' => Role::GUIDE->value,
]);

$this->assertAuthenticated();

$response->assertRedirect(RouteServiceProvider::HOME);
}
}

Let's check all the tests. Great, they are all green!
Previous: Manage Activities and Assign Guides Next Lesson: Homepage

E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


09/13 Homepage
Ok, now our users can manage activities. Time to show them on the public
01 1716 words
front-facing website, starting with the homepage.
02 1702 words

Of course, in a real-life scenario, we would need a here, but


03 1041 words
HTML/CSS things are outside this course's scope. We will stick to the
04 2333 words
default Laravel Breeze design and build a grid view here. As "homework",
you can try to �nd and apply some Tailwind/Bootstrap theme instead or 05 1708 words

create a custom design. 06 2536 words

This is what we'll build in this lesson: 07 4852 words

08 3205 words

09 3332 words

10 1663 words

11 976 words

12 933 words

13 644 words
Here's the list of topics that we'll cover below:

• Modifying Breeze layout for quickly building frontend features.

• Creating a thumbnail image for the activity.

• Showing activities on the homepage and showing the activity itself.

• Adding dummy data so the client can better see how the homepage
will look.

• Writing tests.

Before using Breeze Layout for the homepage, we must make it work for
non-authenticated guest users.

First, create a new `HomeController` and rename the

`resources/views/dashboard.blade.php` into `resources/views


/home.blade.php`.

php artisan make:controller HomeController --invokable

class HomeController extends Controller


{
public function __invoke()
{
return view('home');
}
}

Next, we need to change the Routes to use `HomeController`.

use App\Http\Controllers\HomeController;
Route::get('/', function () {
return view('welcome');
});

Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::get('/', HomeController::class)->name('home');

// ...

Because we removed the `dashboard` route, we need to change the global


setting of where to redirect after the login.

class RouteServiceProvider extends ServiceProvider


{
public const HOME = '/dashboard';
public const HOME = '/';

// ...
}
And in the navigation, besides changing the route name, we need to wrap
links only for authenticated users with the `@auth` Blade directive.

<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">


<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}">
<a href="{{ route('home') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-8
</a>
</div>

<!-- Navigation Links -->


<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
<x-nav-link :href="route('home')" :active="request()->routeIs('home')
{{ __('Dashboard') }}
</x-nav-link>
@auth
@if(auth()->user()->role_id === \App\Enums\Role::ADMINISTRATOR
<x-nav-link :href="route('companies.index')" :active
{{ __('Companies') }}
</x-nav-link>
@endif
@if(auth()->user()->role_id === \App\Enums\Role::COMPANY_OWNER
<x-nav-link :href="route('companies.users.index', auth()->user()
{{ __('Administrators') }}
</x-nav-link>
<x-nav-link :href="route('companies.guides.index', auth()->user(
{{ __('Guides') }}
</x-nav-link>
<x-nav-link :href="route('companies.activities.index', auth()->u
{{ __('Activities') }}
</x-nav-link>
@endif
@endauth
</div>
</div>

<!-- Settings Dropdown -->


<div class="hidden sm:flex sm:items-center sm:ml-6">
@auth
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-
<div>{{ Auth::user()->name }}</div>
<div class="ml-1">
<svg class="fill-current h-4 w-4" xmlns
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.
</svg>
</div>
</button>
</x-slot>

<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')"
{{ __('Profile') }}
</x-dropdown-link>

<!-- Authentication -->


<form method="POST" action="{{ route('logout')
@csrf

<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit
{{ __('Log Out') }}
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
@else
<x-nav-link :href="route('login')" :active="request()->routeIs('login')
{{ __('Login') }}
</x-nav-link>
<x-nav-link :href="route('register')" :active="request()->routeIs('regis
{{ __('Register') }}
</x-nav-link>
@endauth
</div>

<!-- Hamburger -->


<div class="-mr-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-cente
<svg class="h-6 w-6" stroke="currentColor" fill="none"
<path :class="{'hidden': open, 'inline-flex': ! open }
<path :class="{'hidden': ! open, 'inline-flex': open }
</svg>
</button>
</div>
</div>
</div>

<!-- Responsive Navigation Menu -->


<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('d
<x-responsive-nav-link :href="route('home')" :active="request()->routeIs('home')
{{ __('Dashboard') }}
</x-responsive-nav-link>
</div>

<!-- Responsive Settings Options -->


@auth
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth
<div class="font-medium text-sm text-gray-500">{{ Auth
</div>

<div class="mt-3 space-y-1">


<x-responsive-nav-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>

<!-- Authentication -->


<form method="POST" action="{{ route('logout') }}">
@csrf

<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
</div>
</div>
@endauth
</div>
</nav>

Every activity should have a thumbnail to be shown on the homepage.

We will use the `intervention/image` package to make a thumbnail. Yes,


we could use `spatie/laravel-medialibrary,` but I think it would be
overkill for only one image per activity for such a small project, at least for
now.
composer require intervention/image

Because making thumbnail needs to be done both when creating and


editing the activity, we can create a separate private method to avoid
repeating the code.

So the Controller changes would be:

use Intervention\Image\Facades\Image;

class CompanyActivityController extends Controller


{
// ...

public function store(StoreActivityRequest $request, Company $company)


{
$this->authorize('create', $company);

if ($request->hasFile('image')) {
$path = $request->file('image')->store('activities', 'public');
}
$filename = $this->uploadImage($request);

$activity = Activity::create($request->validated() + [
'company_id' => $company->id,
'photo' => $path ?? null,
'photo' => $filename,
]);

$activity->participants()->sync($request->input('guides'));

return to_route('companies.activities.index', $company);


}

// ...

public function update(UpdateActivityRequest $request, Company $company


{
$this->authorize('update', $company);

if ($request->hasFile('image')) {
$path = $request->file('image')->store('activities', 'public');
if ($activity->photo) {
Storage::disk('public')->delete($activity->photo);
}
}
$filename = $this->uploadImage($request);

$activity->update($request->validated() + [
'photo' => $path ?? $activity->photo,
'photo' => $filename ?? $activity->photo,
]);

return to_route('companies.activities.index', $company);


}

// ...

private function uploadImage(StoreActivityRequest|UpdateActivityRequest


{
if (! $request->hasFile('image')) {
return null;
}

$filename = $request->file('image')->store(options: 'activities');

$img = Image::make(Storage::disk('activities')->get($filename))
->resize(274, 274, function ($constraint) {
$constraint->aspectRatio();
});

Storage::disk('activities')->put('thumbs/' . $request->file('image

return $filename;
}
}
As you can see, we are using a disk called `activities` for �le upload. We
need to add this custom disk to the `config/filesystems.php`.

return [
// ...
'disks' => [

// ...

'activities' => [
'driver' => 'local',
'root' => storage_path('app/public/activities'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],

// ...

],
// ...
];
Next, we must delete the image when a new one is uploaded to the edit
page. I think would be a perfect �t.

php artisan make:observer ActivityObserver

use App\Models\Activity;
use App\Observers\ActivityObserver;

class EventServiceProvider extends ServiceProvider


{
// ...

public function boot(): void


{
Activity::observe(ActivityObserver::class);
}

// ...
}

After uploading images, we also have thumbnail images in the


`activites/thumbs` directory.

Now we can show activities on the homepage by paginating them and


showing `No activities` if there are none.

On the homepage, we will show upcoming activities and order them by


`start_time` in a simple 3x3 grid layout, 9 records per page.
:

use App\Models\Activity;

class HomeController extends Controller


{
public function __invoke()
{
$activities = Activity::where('start_time', '>', now())
->orderBy('start_time')
->paginate(9);

return view('home', compact('activities'));


}
}

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Dashboard') }}
</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="p-6 text-gray-900">
<div class="grid grid-cols-4 gap-x-5 gap-y-8">
@forelse($activities as $activity)
<div>
<img src="{{ asset($activity->thumbnail) }}
<h2>
<a href="#" class="text-lg font-semibold
</h2>
<time>{{ $activity->start_time }}</time>
</div>
@empty
<p>No activities</p>
@endforelse
</div>

<div class="mt-6">{{ $activities->links() }}</div>


</div>
</div>
</div>
</div>
</x-app-layout>
This is what we will see:

In the Blade �le, I used `thumbnail` for showing thumbnails, but we don't
have such a �eld in the DB. To display the thumbnail, we will use .
Also, if there is no image for the activity, we will show a default image I took
from the �rst Google result.

:
use Illuminate\Database\Eloquent\Casts\Attribute;

class Activity extends Model


{
// ...

public function thumbnail(): Attribute


{
return Attribute::make(
get: fn() => $this->photo ? '/activities/thumbs/' . $this->photo
);
}
}

Now it is showing a thumbnail image or the default `no_image.jpg`.


Now that we have the list of activities, we can make images and titles
clickable to show the detail page for the activity. But �rst, we need a
Controller and Route.

php artisan make:controller ActivityController

:
use App\Http\Controllers\ActivityController;

Route::get('/', HomeController::class)->name('home');
Route::get('/activities/{activity}', [ActivityController::class, 'show'])->

// ...

For the Route, I am using , meaning we can return a


View and pass the activity to it in the Controller.

use App\Models\Activity;

class ActivityController extends Controller


{
public function show(Activity $activity)
{
return view('activities.show', compact('activity'));
}
}
And in the Blade �le, we just show all the information for now.

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ $activity->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="p-6 text-gray-900 space-y-3">
<img src="{{ asset($activity->thumbnail) }}" alt="{{ $activity
<div>${{ $activity->price }}</div>
<time>{{ $activity->start_time }}</time>
<div>Company: {{ $activity->company->name }}</div>
<p>{{ $activity->description }}</p>
</div>
</div>
</div>
</div>
</x-app-layout>
All that is left is to add a link to the homepage.

// ...
<div class="grid grid-cols-4 gap-5">
@forelse($activities as $activity)
<div>
<a href="{{ route('activity.show', $activity) }}">
<img src="{{ asset($activity->thumbnail) }}" alt="{{ $activity
</a>
<h2>
<a href="#" class="text-lg font-semibold">{{ $activity->name
<a href="{{ route('activity.show', $activity) }}" class="text-lg font-semibo
</h2>
<time>{{ $activity->start_time }}</time>
</div>
@empty
<p>No activities</p>
@endforelse
</div>

// ...
Now, after visiting an activity, we should see a similar result:

We need to add some "fake" data to show this homepage to the client. Of
course, it will be a .

php artisan make:seeder ActivitySeeder

class ActivitySeeder extends Seeder


{
public function run(): void
{
Activity::factory(20)->create();
}
}

And we need to call it.

class DatabaseSeeder extends Seeder


{
public function run(): void
{
$this->call([
RoleSeeder::class,
ActivitySeeder::class,
]);
}
}

Before adding tests for the homepage, we need to �x the seeding process.
In the `TestCase`, we added a `$seed` variable to run the main Seeder, but
we don't need to seed activities every time. However, we can change it to
seed only the roles.

use Database\Seeders\RoleSeeder;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;

protected bool $seed = true;


protected string $seeder = RoleSeeder::class;
}

First, let's add one more assertion for the image upload test. We added a
feature to create a thumbnail, so let's check if a thumbnail has been
created. Also, let's change the disk from `public` to `activities`.

use Tests\TestCase;
use App\Models\User;
use App\Models\Company;
use App\Models\Activity;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;

class CompanyActivityTest extends TestCase


{
// ...

public function test_can_upload_image()


{
Storage::fake('activities');

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

$file = UploadedFile::fake()->image('avatar.jpg');

$this->actingAs($user)->post(route('companies.activities.store', $
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guides' => $guide->id,
'image' => $file,
]);

Storage::disk('public')->assertExists('activities/' . $file->hashName
Storage::disk('activities')->assertExists($file->hashName());
Storage::disk('activities')->assertExists('thumbs/' . $file->hashName
}
public function test_cannon_upload_non_image_file()
{
Storage::fake('activities');

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

$file = UploadedFile::fake()->create('document.pdf', 2000, 'application/pdf

$response = $this->actingAs($user)->post(route('companies.activities.store
'name' => 'activity',
'description' => 'description',
'start_time' => '2023-09-01 10:00',
'price' => 9999,
'guides' => $guide->id,
'image' => $file,
]);

$response->assertSessionHasErrors(['image']);

Storage::disk('public')->assertMissing('activities/' . $file->hashName
Storage::disk('activities')->assertMissing($file->hashName());
}

// ...
}
Now let's test the homepage:

• Page can be accessed for both guests and authenticated users.

• If there are no activities message `No activities` is shown.

• When there aren't enough activities pagination link isn't shown.

• On the second page, the correct activity is shown.

• And activities are shown in the correct order.

php artisan make:test HomePageTest

:
use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;

class HomePageTest extends TestCase


{
use RefreshDatabase;

public function test_unauthenticated_user_can_access_home_page()


{
$response = $this->get(route('home'));

$response->assertOk();
}

public function test_authenticated_user_can_access_home_page()


{
$user = User::factory()->create();

$response = $this->actingAs($user)->get(route('home'));

$response->assertOk();
}
public function test_show_no_activities_when_theres_no_upcoming_activities
{
$response = $this->get(route('home'));

$response->assertSeeText('No activities');
}

public function test_pagination_isnt_shown_when_activities_are_9()


{
Activity::factory(9)->create();

$response = $this->get(route('home'));

$response->assertDontSee('Next');
}

public function test_pagination_shows_correct_results()


{
Activity::factory(9)->create();
$activity = Activity::factory()->create(['start_time' => now()->addYear

$response = $this->get(route('home'));

$response->assertSee('Next');

$response = $this->get(route('home') . '/?page=2');


$response->assertSee($activity->name);
}

public function test_order_by_start_time_is_correct()


{
$activity = Activity::factory()->create(['start_time' => now()->addWeek
$activity2 = Activity::factory()->create(['start_time' => now()->addMonth
$activity3 = Activity::factory()->create(['start_time' => now()->addMonths

$response = $this->get(route('home'));

$response->assertSeeTextInOrder([
$activity->name,
$activity2->name,
$activity3->name,
]);
}
}

And another test for the activity show. For now, we will just test that page
return status `200` if activity exists and `404` if it doesn't exist.
php artisan make:test ActivityShowTest

use Tests\TestCase;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ActivityShowTest extends TestCase


{
use RefreshDatabase;

public function test_can_view_activity_page()


{
$activity = Activity::factory()->create();

$response = $this->get(route('activity.show', $activity));

$response->assertOk();
}

public function test_gets_404_for_unexisting_activity()


{
$response = $this->get(route('activity.show', 69));

$response->assertNotFound();
}
}

Previous: After the First Client Review Next Lesson: Registration to Activity

E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!
© 2023 Laravel Daily · [email protected]
10/13 Registration to Activity
Now that we show activities, we need a button to allow the user to register
01 1716 words
for an activity. If the user is a guest, he will �rst be redirected to the
02 1702 words
registration page and, after registration, will be registered to the activity.
Then, after successful registration for the activity, user will be redirected to 03 1041 words

the `My Activities` page and will receive an email.


04 2333 words

05 1708 words

06 2536 words

07 4852 words

08 3205 words

09 3332 words
The `Register to activity` button will make a `POST` request. So, �rst,
let's add this button to the show activities page above the thumbnail image. 10 1663 words

11 976 words
:
12 933 words

13 644 words
// ...
<div class="p-6 text-gray-900 space-y-3">
@if(auth()->user()?->activities->contains($activity)) {{-- [tl! add:start
<div class="mb-6 bg-indigo-100 p-4 font-semibold text-indigo-700">
@else
<form action="{{ route('activities.register', $activity) }}" method
@csrf

<x-secondary-button type="submit">
Register to Activity
</x-secondary-button>
</form>
@endif {{-- [tl! add:end] --}}

<img src="{{ asset($activity->thumbnail) }}" alt="{{ $activity->name }}


<div>${{ $activity->price }}</div>
<time>{{ $activity->start_time }}</time>
<div>Company: {{ $activity->company->name }}</div>
<p>{{ $activity->description }}</p>
</div>
// ...
</x-app-layout>

As you can see here, I also added an if-statement: we won't show a button
if the user is already registered for the activity. But for this to work, we must
add an `activities` relation to the `User` Model.

:
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class User extends Authenticatable


{
// ...

public function activities(): BelongsToMany


{
return $this->belongsToMany(Activity::class)->withTimestamps();
}
}

When the user isn't registered for the activity, the expected result looks like
this:
Otherwise, if the user is already registered, the expected result will look like
this:

Next, we need a Controller and a Route.

php artisan make:controller ActivityRegisterController

use App\Http\Controllers\ActivityRegisterController;
Route::get('/', HomeController::class)->name('home');
Route::get('/activities/{activity}', [ActivityController::class, 'show'])->
Route::post('/activities/{activity}/register', [ActivityRegisterController

// ...

In the Controller, we need to perform a couple of things:

• If the user is a guest, redirect to the `register` route with the


`activity` as a `GET` parameter.

• Abort the action if the authenticated user tries to register more than
once.

• Attach activity for the user.

• Send a Noti�cation to the user.

• And redirect to the `My activities` page with the success message.

use App\Models\Activity;
use Symfony\Component\HttpFoundation\Response;
class ActivityRegisterController extends Controller
{
public function store(Activity $activity)
{
if (! auth()->check()) {
return to_route('register', ['activity' => $activity->id]);
}

abort_if(auth()->user()->activities()->where('id', $activity->id)->

auth()->user()->activities()->attach($activity->id);

return to_route('my-activity.show')->with('success', 'You have successfully register


}
}

Let's quickly create a `My activities` page (blank for now) so that redirect
would work.

php artisan make:controller MyActivityController


:

use App\Http\Controllers\MyActivityController;

// ...

Route::middleware('auth')->group(function () {
Route::get('/activities', [MyActivityController::class, 'show'])->name

// ...
});

class MyActivityController extends Controller


{
public function show()
{
return view('activities.my-activities');
}
}
:

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('My Activities') }}
</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="p-6 text-gray-900">
@if(session('success'))
<div class="mb-6 bg-indigo-100 p-4 text-indigo-700
@endif

My Activities
</div>
</div>
</div>
</div>
</x-app-layout>
For now, it will only show the text `My Activities`, and will show the
success message if it exists.

Now let's get back to registering user to the activity, and let's create a
Noti�cation and send it.

php artisan make:notification RegisteredToActivityNotification

use App\Models\Activity;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class RegisteredToActivityNotification extends Notification
{
public function __construct(private readonly Activity $activity)
{}

public function via($notifiable): array


{
return ['mail'];
}

public function toMail($notifiable): MailMessage


{
return (new MailMessage)
->subject('You have successfully registered')
->line('Thank you for registering to the activity ' . $this->activity->name
->line('Start time ' . $this->activity->start_time);
}

public function toArray($notifiable): array


{
return [];
}
}
:

use App\Notifications\RegisteredToActivityNotification;

class ActivityRegisterController extends Controller


{
public function store(Activity $activity)
{
if (! auth()->check()) {
return to_route('register', ['activity' => $activity->id]);
}

abort_if(auth()->user()->activities()->where('id', $activity->id)->

auth()->user()->activities()->attach($activity->id);

auth()->user()->notify(new RegisteredToActivityNotification($activity

return to_route('my-activity.show')->with('success', 'You have successfully register


}
}

The mail message looks like this:


Now that authenticated users can register for the activity, let's implement
registration for .

We have already made the redirect to the `register` page. I think we can
use Sessions again to automatically register to the activity as we did with
the invitations.

First, we need to set the session.

class RegisteredUserController extends Controller


{
public function create(Request $request): View
{
// ...

if ($request->has('activity')) {
session()->put('activity', $request->input('activity'));
}

return view('auth.register', compact('email'));


}
// ...
}

And then, after the submit button is clicked, we need to:

• Get the activity and check if the session is set.

• Attach activity to the just registered user.

• Send a Noti�cation to the user.

• Redirect to the `My activities` route with the success message.

use App\Models\Activity;
use App\Notifications\RegisteredToActivityNotification;

class RegisteredUserController extends Controller


{
// ...

public function store(Request $request): RedirectResponse


{
// ...
$activity = Activity::find($request->session()->get('activity'));
if ($request->session()->get('activity') && $activity) {
$user->activities()->attach($request->session()->get('activity

$user->notify(new RegisteredToActivityNotification($activity));

return redirect()->route('my-activity.show')->with('success',
}

return redirect(RouteServiceProvider::HOME);
}
}

After registering to the activity, we should be redirected to the `My


activities` page with the success message.
And now the last part of every lesson: tests. We will test the following:

• The text for the user who is/isn't registered to the activity is correct.

• Authenticated users can register for the activity, receive a noti�cation,


and are redirected to the `My activities` page.

• Authenticated users cannot register more than once for the same
activity.

• Guest gets redirected to the `Register` page with the correct `GET`
parameter.

• After registering, the user gets a Noti�cation and is redirected to the


`My activities` page.

php aritsan make:test RegisterActivityTest


:

use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Support\Facades\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Notifications\RegisteredToActivityNotification;

class RegisterActivityTest extends TestCase


{
use RefreshDatabase;

public function test_shows_register_button_if_user_hasnt_registered_to_activity


{
$user = User::factory()->create();
$activity = Activity::factory()->create();

$response = $this->actingAs($user)->get(route('activity.show', $activity

$response->assertSeeText('Register to Activity');
}

public function test_shows_already_registered_when_user_is_registered_to_activity


{
$user = User::factory()->create();
$activity = Activity::factory()->create();
$user->activities()->attach($activity);

$response = $this->actingAs($user)->get(route('activity.show', $activity

$response->assertSeeText('You have already registered.');


$response->assertDontSeeText('Register to Activity');
}

public function test_authenticated_user_can_register_to_activity()


{
Notification::fake();

$user = User::factory()->create();
$activity = Activity::factory()->create();

$response = $this->actingAs($user)->post(route('activities.register

Notification::assertSentTo($user, RegisteredToActivityNotification

$response->assertRedirect(route('my-activity.show'));

$this->assertCount(1, $user->activities()->get());
}

public function test_authenticated_user_cannot_register_twice_to_activity


{
Notification::fake();

$user = User::factory()->create();
$activity = Activity::factory()->create();

$response = $this->actingAs($user)->post(route('activities.register
$response->assertRedirect(route('my-activity.show'));

$r = $this->actingAs($user)->post(route('activities.register', $activity
$r->assertStatus(409);

$this->assertCount(1, $user->activities()->get());

Notification::assertSentTimes(RegisteredToActivityNotification::class
}

public function test_guest_gets_redirected_to_register_page()


{
$activity = Activity::factory()->create();

$response = $this->post(route('activities.register', $activity));

$response->assertRedirect(route('register'). '?activity=' . $activity


}

public function test_guest_registers_to_activity()


{
Notification::fake();

$activity = Activity::factory()->create();

$response = $this->withSession(['activity' => $activity->id])->post


'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);

Notification::assertSentTo(User::find(1), RegisteredToActivityNotification

$response->assertRedirect(route('my-activity.show'));
}
}

Previous: Homepage Next Lesson: My Activities and Cancel


E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


11/13 My Activities and Cancel
Now, let's show activities on the `My activities` page and add a button to
01 1716 words
cancel them.
02 1702 words

03 1041 words

04 2333 words

05 1708 words

We already have the Controller and Blade �le for the `My activities` page. 06 2536 words

But we need a link in the navigation to access it. So �rst, let's add that link 07 4852 words

for the authenticated user under the `Profile` navigation item.


08 3205 words

: 09 3332 words

10 1663 words

11 976 words
// ...
<x-dropdown-link :href="route('profile.edit')">
12 933 words
{{ __('Profile') }}
</x-dropdown-link> 13 644 words

<x-dropdown-link :href="route('my-activity.show')">
{{ __('My Activities') }}
</x-dropdown-link>
// ...
This is how it looks in the dropdown:

Now let's show the data! First, we need to get it in the Controller and order
by `start_time`.

use App\Models\Activity;

class MyActivityController extends Controller


{
public function show()
{
$activities = auth()->user()->activities()->orderBy('start_time')->

return view('activities.my-activities', compact('activities'));


}
}

And in the Blade, show it in the grid layout.

// ...
<div class="p-6 text-gray-900">
@if(session('success'))
<div class="mb-6 bg-indigo-100 p-4 text-indigo-700">{{ session('success
@endif

My Activities
<div class="grid grid-cols-4 gap-5">
@forelse($activities as $activity)
<div class="space-y-3">
<a href="{{ route('activity.show', $activity) }}">
<img src="{{ asset($activity->thumbnail) }}" alt="{{ $activity
<h2>
<a href="{{ route('activity.show', $activity) }}" class
</h2>
<time>{{ $activity->start_time }}</time>
</div>
@empty
<p>No activities</p>
@endforelse
</div>
</div>
// ...
</x-app-layout>

The user will see a list similar to this:


Canceling the activity is straightforward. We need to detach the activity
from the user activities relationship.

First, we will add a new Route and a button to cancel activity below the
start time.

// ...

Route::middleware('auth')->group(function () {
Route::get('/activities', [MyActivityController::class, 'show'])->name
Route::delete('/activities/{activity}', [MyActivityController::class,

// ...
});

// ...
<div class="grid grid-cols-4 gap-5">
@forelse($activities as $activity)
<div class="space-y-3">
<a href="{{ route('activity.show', $activity) }}">
<img src="{{ asset($activity->thumbnail) }}" alt="{{ $activity
<h2>
<a href="{{ route('activity.show', $activity) }}" class="text-lg font-semibo
</h2>
<time>{{ $activity->start_time }}</time>
<form action="{{ route('my-activity.destroy', $activity) }}" method
@csrf
@method('DELETE')
<x-danger-button>
Cancel
</x-danger-button>
</form>
</div>
@empty
<p>No activities</p>
@endforelse
</div>
// ...

Now after every activity, we see a Cancel button.


All that is left is to implement the logic. Before detaching and redirecting
back, we need to check that activity belongs to a user and, if not, abort the
action.

use Symfony\Component\HttpFoundation\Response;

class MyActivityController extends Controller


{
// ...

public function destroy(Activity $activity)


{
abort_if(! auth()->user()->activities->contains($activity), Response

auth()->user()->activities()->detach($activity);

return to_route('my-activity.show')->with('success', 'Activity removed.


}
}
And let's add the tests for the `My activity` page. Here we will test:

• User sees only their own activities.

• The user can cancel their activities and cannot cancel for other users.

php artisan make:test MyActivityTest

use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;

class MyActivityTest extends TestCase


{
use RefreshDatabase;

public function test_my_activities_does_not_show_other_users_activities


{
$user = User::factory()->create();
$activity = Activity::factory()->create();
$user->activities()->attach($activity);

$user2 = User::factory()->create();
$activity2 = Activity::factory()->create();
$user2->activities()->attach($activity2);

$response = $this->actingAs($user)->get(route('my-activity.show'));

$response->assertSeeText($activity->name);
$response->assertDontSeeText($activity2->name);
}

public function test_my_activities_shows_order_by_time_correctly()


{
$user = User::factory()->create();
$activity = Activity::factory()->create(['start_time' => now()->addWeek
$activity2 = Activity::factory()->create(['start_time' => now()->addMonth
$activity3 = Activity::factory()->create(['start_time' => now()->addMonths
$response = $this->actingAs($user)->get(route('home'));

$response->assertSeeTextInOrder([
$activity->name,
$activity2->name,
$activity3->name,
]);
}

public function test_can_cancel_activity()


{
$user = User::factory()->create();
$activity = Activity::factory()->create();
$user->activities()->attach($activity);

$response = $this->actingAs($user)->delete(route('my-activity.destroy

$response->assertRedirect(route('my-activity.show'));

$this->assertCount(0, $user->activities()->get());
}

public function test_cannot_cancel_activity_for_other_user()


{
$user = User::factory()->create();
$activity = Activity::factory()->create();
$user->activities()->attach($activity);
$user2 = User::factory()->create();

$response = $this->actingAs($user2)->delete(route('my-activity.destroy

$response->assertForbidden();

$this->assertCount(1, $user->activities()->get());
}
}

Previous: Registration to Activity Next Lesson: Guide Activities


E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


12/13 Guide Activities
In this lesson, we will add a page where the user with the `Guide` role will
01 1716 words
be able to see activities assigned to them.
02 1702 words

03 1041 words

04 2333 words

05 1708 words

The page `My activities` page for guides will be almost identical to the 06 2536 words

one we have just built for the regular users. The only difference will be the 07 4852 words

button `Export to PDF` instead of `Cancel`. The export functionality will be


08 3205 words
added in the next lesson.
09 3332 words

First, let's create a new Route and Controller. The Route endpoint will be
10 1663 words
different for the guides.
11 976 words

12 933 words

php artisan make:controller GuideActivityController


13 644 words

:
use App\Http\Controllers\GuideActivityController;

// ...

Route::middleware('auth')->group(function () {
Route::get('/activities', [MyActivityController::class, 'show'])->name
Route::get('/guides/activities', [GuideActivityController::class, 'show
Route::delete('/activities/{activity}', [MyActivityController::class,

// ...
});

Now we need to send only the users with the role of `Guide` to this new
page. Again, we will make a simple if-statement because this check is not
repeating elsewhere.

// ...
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
@if(auth()->user()->role_id === \App\Enums\Role::GUIDE->value)
<x-dropdown-link :href="route('guide-activity.show')">
{{ __('My Activities') }}
</x-dropdown-link>
@else
<x-dropdown-link :href="route('my-activity.show')">
{{ __('My Activities') }}
</x-dropdown-link>
@endif
// ...

And now, we can get activities assigned to the user and order them by
`start_time`. Also, this page must be accessed only by users with the

guide role. So, we will abort with the forbidden message if others try to
access it.

use App\Enums\Role;
use App\Models\Activity;
use Symfony\Component\HttpFoundation\Response;

class GuideActivityController extends Controller


{
public function show()
{
abort_if(auth()->user()->role_id !== Role::GUIDE->value, Response::

$activities = Activity::where('guide_id', auth()->id())->orderBy('

return view('activities.guide-activities', compact('activities'));


}
}

And here's the Blade �le to show the activities.

<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('My Activities') }}
</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="p-6 text-gray-900">
<div class="grid grid-cols-4 gap-5">
@forelse($activities as $activity)
<div class="space-y-3">
<a href="{{ route('activity.show', $activity
<img src="{{ asset($activity->thumbnail
<h2>
<a href="{{ route('activity.show', $activity
</h2>
<time>{{ $activity->start_time }}</time>
</div>
@empty
<p>No activities</p>
@endforelse
</div>
</div>
</div>
</div>
</div>
</x-app-layout>

: In the showing of activities, we have the same code in three places.


So it would be better to extract it into a component. But because this is just
a mock-up to show the client how the functionality works quickly, I think it
is not worth it. When the �nal design is implemented, then I would refactor
every repeated part into a Blade component.

Now the `My activities` page should look like this:

So, what will we test in this lesson?


• User with the guide role can access the page and other users cannot.

• Guide sees activities only assigned to them.

• Guide sees activities in the correct order.

php artisan make:test GuideActivityTest

use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;

class GuideActivityTest extends TestCase


{
use RefreshDatabase;

public function test_guide_can_access_my_activities_page()


{
$user = User::factory()->guide()->create();
$response $this->actingAs($user)->get(route( guide-activity.show

$response->assertOk();
}

public function test_other_user_cannot_access_guide_activities_page()


{
$user = User::factory()->create();

$response = $this->actingAs($user)->get(route('guide-activity.show

$response->assertForbidden();
}

public function test_guides_sees_activities_only_assigned_to_him()


{
$user = User::factory()->guide()->create();
$activity = Activity::factory()->create(['guide_id' => $user->id]);
$activity2 = Activity::factory()->create();

$response = $this->actingAs($user)->get(route('guide-activity.show

$response->assertSeeText($activity->name);
$response->assertDontSeeText($activity2->name);
}

public function test_guide_sees_activities_ordered_by_time_correctly()


{
$user = User::factory()->guide()->create();
$activity = Activity::factory()->create(['guide_id' => $user->id,
$activity2 = Activity::factory()->create(['guide_id' => $user->id,
$activity3 = Activity::factory()->create(['guide_id' => $user->id,

$response = $this->actingAs($user)->get(route('guide-activity.show

$response->assertSeeTextInOrder([
$activity->name,
$activity2->name,
$activity3->name,
]);
}
}

Previous: My Activities and Cancel Next Lesson: Guide: Export PDF


E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]


13/13 Guide: Export PDF
In the last lesson, we will allow users with the guide role to export activities
01 1716 words
into a PDF �le. The PDF will show the activity name, start time, and a list of
02 1702 words
participants in a table.
03 1041 words

04 2333 words

05 1708 words

06 2536 words

07 4852 words

08 3205 words

09 3332 words

10 1663 words

11 976 words

12 933 words

13 644 words

We will use the package for exporting PDF. It's a


Laravel wrapper for the DOMPDF package.
composer require barryvdh/laravel-dompdf

Next, we need a Route and a button below the activity.

// ...

Route::middleware('auth')->group(function () {
Route::get('/activities', [MyActivityController::class, 'show'])->name
Route::get('/guides/activities', [GuideActivityController::class, 'show
Route::get('/guides/activities/{activity}/pdf', [GuideActivityController
Route::delete('/activities/{activity}', [MyActivityController::class,

// ...
});

// ...
<div class="space-y-3">
<a href="{{ route('activity.show', $activity) }}">
<img src="{{ asset($activity->thumbnail) }}" alt="{{ $activity->name
<h2>
<a href="{{ route('activity.show', $activity) }}" class="text-lg font-semibold
</h2>
<time>{{ $activity->start_time }}</time>
<a href="{{ route('guide-activity.export', $activity) }}" class="inline-flex items-cente
Download PDF
</a>
</div>
// ...
Now we can use the package to download the PDF.

use Barryvdh\DomPDF\Facade\Pdf;

class GuideActivityController extends Controller


{
// ...

public function export(Activity $activity)


{
abort_if(auth()->user()->role_id !== Role::GUIDE->value, Response::

$data = $activity->load(['participants' => function($query) {


$query->orderByPivot('created_at');
}]);

return Pdf::loadView('activities.pdf', ['data' => $data])->download


}
}

We are using Blade for outputting data.


:

<h1>{{ $data->name }}</h1>

<h4>Start time {{ $data->start_time }}</h4>

<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Email</th>
<th>Registration Time</th>
</tr>
</thead>
<tbody>
@foreach($data->participants as $participant)
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $participant->name }}</td>
<td>{{ $participant->email }}</td>
<td>{{ $participant->pivot->created_at }}</td>
</tr>
@endforeach
</tbody>
</table>

There isn't much that we can test with the PDF. We will add one test to the
`GuideActivityTest` to check if PDF isn't empty, the content type is

`application/pdf`, and the �lename is correct.


:

class GuideActivityTest extends TestCase


{
// ...

public function test_pdf_export()


{
$guide = User::factory()->guide()->create();
$activity = Activity::factory()->create(['guide_id' => $guide->id]);

$response = $this->actingAs($guide)->get(route('guide-activity.export

$this->assertNotEmpty($response->getContent());
$this->assertEquals('application/pdf', $response->headers->get('Content-Type
$this->assertEquals('attachment; filename="' . $activity->name .'.pdf"
}
}
So yeah, quite a long journey, but we've �nished the course.
Congratulations on making it this far!

The repository is available .

Previous: Guide Activities

E-mail address

You can unsubscribe at any time. You'll also get -20% off my courses!

© 2023 Laravel Daily · [email protected]

You might also like