Laravel Reservations Course Main
Laravel Reservations Course Main
02 1702 words
03 1041 words
04 2333 words
Laravel 10
06 2536 words
08 3205 words
09 3332 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!
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.
Also, I immediately try to envision the �elds of the tables, cause that may
lead to follow-up questions to the client
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.
So...
// ...
Next, creating .
:
Schema::create('companies', function (Blueprint $table) {
$table->id();
$table->string('name');
$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.
protected $fillable = [
'company_id',
'guide_id',
'name',
'description',
'start_time',
'price',
'photo'
];
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.
• View MY activities
• Cancel my reservations
Looks cool to me. Now, let's try to that list and transform it into a
plan of action.
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)
11. Login with customer role: view my activities and cancel them
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".
5. What should the PDF for the guide look like? Do you have any
examples?
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?
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!
04 2333 words
05 1708 words
06 2536 words
08 3205 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.
use App\Models\Role;
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.
use App\Enums\Role;
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role_id' => Role::CUSTOMER->value,
]);
// ...
}
}
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.
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.
• `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;
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
Rules in the form request for both save and update are the same.
use Illuminate\Http\RedirectResponse;
use App\Http\Requests\StoreCompanyRequest;
use App\Http\Requests\UpdateCompanyRequest;
return to_route('companies.index');
}
return to_route('companies.index');
}
}
:
<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.
return to_route('companies.index');
}
}
That's it! We have fully working companies CRUD. Let's move on to the next
lesson.
E-mail address
You can unsubscribe at any time. You'll also get -20% off my courses!
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
09 3332 words
11 976 words
: 12 933 words
13 644 words
// ...
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);
}
}
in the Kernel, you can skip this suf�x and shorten it however you want, like
`isAdmin` in my case.
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.
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>
// ...
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▕ /**
use App\Enums\Role;
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;
$response = $this->actingAs($user)->get(route('companies.index'));
$response->assertOk();
}
$response = $this->actingAs($user)->get(route('companies.index'));
$response->assertForbidden();
}
}
• 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.
• 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`.
You can unsubscribe at any time. You'll also get -20% off my courses!
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
: Can one "company owner" user belong to more than one 10 1663 words
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`.
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`
// ...
// ...
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;
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;
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
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.
use App\Models\User;
use Illuminate\Validation\Rules;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
use App\Enums\Role;
use App\Models\User;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
<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.
Now let's add tests. The plan is to check that user with the `administrator`
role can perform every CRUD action.
:
class CompanyFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->words(3),
];
}
}
use App\Models\User;
use App\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CompanyUserTest extends TestCase
{
use RefreshDatabase;
$response = $this->actingAs($user)->get(route('companies.users.index
$response->assertOk();
}
$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]',
]);
}
$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]',
]);
}
$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.
You can unsubscribe at any time. You'll also get -20% off my courses!
04 2333 words
05 1708 words
06 2536 words
First, let's add `SoftDeletes` for the User Model if someone accidentally 07 4852 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;
// ...
}
Let's �x it.
:
$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!
PASS Tests\Feature\ProfileTest
✓ user can delete their account 0.13s
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
// ...
<!-- 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.
use App\Models\Company;
use App\Policies\CompanyUserPolicy;
// ...
}
• viewAny
• create
• update
• delete
And we will allow those actions based on user's role `Company Owner` and
their company ID.
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;
}
:
class CompanyUserController extends Controller
{
public function index(Company $company)
{
$this->authorize('viewAny', $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.
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
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.
$response = $this->actingAs($user)->get(route('companies.users.index
$response->assertOk()
->assertSeeText($secondUser->name);
}
$response = $this->actingAs($user)->get(route('companies.users.index
$response->assertForbidden();
}
$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,
]);
}
$response = $this->actingAs($user)->post(route('companies.users.store
'name' => 'test user',
'email' => '[email protected]',
'password' => 'password',
]);
$response->assertForbidden();
}
$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,
]);
}
$response = $this->actingAs($user)->put(route('companies.users.update
'name' => 'updated user',
'email' => '[email protected]',
]);
$response->assertForbidden();
}
$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]',
]);
}
$response = $this->actingAs($user)->delete(route('companies.users.update
$response->assertForbidden();
}
}
You can unsubscribe at any time. You'll also get -20% off my courses!
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
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
12 933 words
13 644 words
So, as always, we need a Controller and to add Routes. Again this Controller
will be a .
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 .
use App\Enums\Role;
use App\Models\User;
use App\Models\Company;
use App\Http\Requests\StoreGuideRequest;
use App\Http\Requests\UpdateGuideRequest;
$company->users()->create([
'name' => $request->input('name'),
'email' => $request->input('email'),
'password' => bcrypt($request->input('password')),
'role_id' => Role::GUIDE->value,
]);
$guide->delete();
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
<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;
use App\Models\User;
use App\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
$response = $this->actingAs($user)->get(route('companies.guides.index
$response->assertOk()
->assertSeeText($secondUser->name);
}
$response = $this->actingAs($user)->get(route('companies.guides.index
$response->assertForbidden();
}
$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,
]);
}
$response = $this->actingAs($user)->post(route('companies.guides.store
'name' => 'test user',
'email' => '[email protected]',
'password' => 'password',
]);
$response->assertForbidden();
}
$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,
]);
}
$response = $this->actingAs($user)->put(route('companies.guides.update
'name' => 'updated user',
'email' => '[email protected]',
]);
$response->assertForbidden();
}
$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]',
]);
}
$response = $this->actingAs($user)->delete(route('companies.guides.update
$response->assertForbidden();
}
}
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!
• Will make it work for both `company owner` and `administrator` users. 05 1708 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`.
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
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 .
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;
<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
use Illuminate\Database\Eloquent\Casts\Attribute;
<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
<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.
use App\Models\Activity;
use App\Policies\ActivityPolicy;
In the Policy, we will check if the user has the `Company Owner` role and is
doing the action for his company.
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;
}
// ...
}
// ...
}
// ...
}
// ...
}
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>
:
use App\Models\Company;
use Illuminate\Support\Carbon;
• 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.
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;
$response = $this->actingAs($user)->get(route('companies.activities.index
$response->assertOk();
}
$response = $this->actingAs($user)->get(route('companies.activities.index
$response->assertSeeText($activity->name)
->assertDontSeeText($activity2->name);
}
$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
}
$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());
}
$company2 = Company::factory()->create();
$guide2 = User::factory()->guide()->create(['company_id' => $company2
$response = $this->actingAs($user)->get(route('companies.activities.create
$company2 = Company::factory()->create();
$guide2 = User::factory()->guide()->create(['company_id' => $company2
$response = $this->actingAs($user)->get(route('companies.activities.edit
$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,
]);
}
$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();
}
$response = $this->actingAs($user)->delete(route('companies.activities.destroy
$response->assertRedirect(route('companies.activities.index', $company
$this->assertModelMissing($activity);
}
$response = $this->actingAs($user)->delete(route('companies.activities.destroy
$this->assertModelExists($activity);
$response->assertForbidden();
}
$response->assertOk();
}
$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,
]);
}
$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!
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
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.
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>
//
@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">
<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">
<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">
<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.
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;
<x-mail::message>
# You Have Been Invited
<x-mail::button :url="$inviteUrl">
Register
</x-mail::button>
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
use App\Models\UserInvitation;
use Illuminate\Support\Str;
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
$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
// ...
}
use App\Models\UserInvitation;
use Illuminate\Support\Str;
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
$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
// ...
}
use App\Models\UserInvitation;
if ($request->has('invitation_token')) {
$token = $request->input('invitation_token');
session()->put('invitation_token', $token);
$email = $invitation->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;
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;
$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
// ...
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;
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.
`test_company_owner_can_create_user_to_his_company`.
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
$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,
]);
}
$this->actingAs($user)->post(route('companies.users.store', $company
'email' => '[email protected]',
]);
$response = $this->actingAs($user)->post(route('companies.users.store
'email' => '[email protected]',
]);
// ...
$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;
// ...
$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]',
]);
// ...
}
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;
$this->actingAs($user)->post(route('companies.users.store', $company
'email' => '[email protected]',
]);
Auth::logout();
$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);
}
$this->actingAs($user)->post(route('companies.guides.store', $company
'email' => '[email protected]',
]);
$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!
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:
• 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.
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');
// ...
// ...
}
And in the navigation, besides changing the route name, we need to wrap
links only for authenticated users with the `@auth` Blade directive.
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')"
{{ __('Profile') }}
</x-dropdown-link>
<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>
<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>
use Intervention\Image\Facades\Image;
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'));
// ...
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,
]);
// ...
$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.
use App\Models\Activity;
use App\Observers\ActivityObserver;
// ...
}
use App\Models\Activity;
<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>
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;
:
use App\Http\Controllers\ActivityController;
Route::get('/', HomeController::class)->name('home');
Route::get('/activities/{activity}', [ActivityController::class, 'show'])->
// ...
use App\Models\Activity;
<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 .
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;
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;
$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();
$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:
:
use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;
$response->assertOk();
}
$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');
}
$response = $this->get(route('home'));
$response->assertDontSee('Next');
}
$response = $this->get(route('home'));
$response->assertSee('Next');
$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;
$response->assertOk();
}
$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
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] --}}
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;
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:
use App\Http\Controllers\ActivityRegisterController;
Route::get('/', HomeController::class)->name('home');
Route::get('/activities/{activity}', [ActivityController::class, 'show'])->
Route::post('/activities/{activity}/register', [ActivityRegisterController
// ...
• Abort the action if the authenticated user tries to register more than
once.
• 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);
Let's quickly create a `My activities` page (blank for now) so that redirect
would work.
use App\Http\Controllers\MyActivityController;
// ...
Route::middleware('auth')->group(function () {
Route::get('/activities', [MyActivityController::class, 'show'])->name
// ...
});
<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.
use App\Models\Activity;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class RegisteredToActivityNotification extends Notification
{
public function __construct(private readonly Activity $activity)
{}
use App\Notifications\RegisteredToActivityNotification;
abort_if(auth()->user()->activities()->where('id', $activity->id)->
auth()->user()->activities()->attach($activity->id);
auth()->user()->notify(new RegisteredToActivityNotification($activity
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.
if ($request->has('activity')) {
session()->put('activity', $request->input('activity'));
}
use App\Models\Activity;
use App\Notifications\RegisteredToActivityNotification;
$user->notify(new RegisteredToActivityNotification($activity));
return redirect()->route('my-activity.show')->with('success',
}
return redirect(RouteServiceProvider::HOME);
}
}
• The text for the user who is/isn't registered to the activity is correct.
• Authenticated users cannot register more than once for the same
activity.
• Guest gets redirected to the `Register` page with the correct `GET`
parameter.
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;
$response->assertSeeText('Register to Activity');
}
$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());
}
$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
}
$activity = Activity::factory()->create();
Notification::assertSentTo(User::find(1), RegisteredToActivityNotification
$response->assertRedirect(route('my-activity.show'));
}
}
You can unsubscribe at any time. You'll also get -20% off my courses!
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
: 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;
// ...
<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>
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>
// ...
use Symfony\Component\HttpFoundation\Response;
auth()->user()->activities()->detach($activity);
• The user can cancel their activities and cannot cancel for other users.
use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;
$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);
}
$response->assertSeeTextInOrder([
$activity->name,
$activity2->name,
$activity3->name,
]);
}
$response = $this->actingAs($user)->delete(route('my-activity.destroy
$response->assertRedirect(route('my-activity.show'));
$this->assertCount(0, $user->activities()->get());
}
$response = $this->actingAs($user2)->delete(route('my-activity.destroy
$response->assertForbidden();
$this->assertCount(1, $user->activities()->get());
}
}
You can unsubscribe at any time. You'll also get -20% off my courses!
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
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
:
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;
<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>
use Tests\TestCase;
use App\Models\User;
use App\Models\Activity;
use Illuminate\Foundation\Testing\RefreshDatabase;
$response->assertOk();
}
$response = $this->actingAs($user)->get(route('guide-activity.show
$response->assertForbidden();
}
$response = $this->actingAs($user)->get(route('guide-activity.show
$response->assertSeeText($activity->name);
$response->assertDontSeeText($activity2->name);
}
$response = $this->actingAs($user)->get(route('guide-activity.show
$response->assertSeeTextInOrder([
$activity->name,
$activity2->name,
$activity3->name,
]);
}
}
You can unsubscribe at any time. You'll also get -20% off my courses!
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
// ...
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;
<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
$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!
E-mail address
You can unsubscribe at any time. You'll also get -20% off my courses!