Courses

Laravel 11: Small Reservation Project Step-By-Step

Homepage

Ok, now our users can manage activities. Time to show them on the public front-facing website, starting with the homepage.

Of course, in a real-life scenario, we would need a visual design here, but HTML/CSS things are outside this course's scope. We will stick to the default Laravel Breeze design and build a grid view here. As "homework", you can try to find and apply some Tailwind/Bootstrap theme instead or create a custom design.

This is what we'll build in this lesson:

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.

Modifying Breeze Layout

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

First, create a new invokable HomeController and rename the resources/views/dashboard.blade.php into resources/views/home.blade.php.

php artisan make:controller HomeController --invokable

app/Http/Controllers/HomeController.php:

class HomeController extends Controller
{
public function __invoke()
{
return view('home');
}
}

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

routes/web.php:

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 this route to home route instead everywhere.

app/Http/Controllers/Auth/AuthenticatedSessionController.php:

class AuthenticatedSessionController extends Controller
{
// ...
 
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
 
$request->session()->regenerate();
 
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended(route('home', absolute: false));
}
 
// ...
}

app/Http/Controllers/Auth/ConfirmablePasswordController.php:

class ConfirmablePasswordController extends Controller
{
// ...
 
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
 
$request->session()->put('auth.password_confirmed_at', time());
 
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended(route('home', absolute: false));
}
}

app/Http/Controllers/Auth/RegisteredUserController.php:

class RegisteredUserController extends Controller
{
// ...
 
public function store(Request $request): RedirectResponse
{
// ...
 
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended(route('home', absolute: false));
}
}

app/Http/Controllers/Auth/VerifyEmailController.php:

class VerifyEmailController extends Controller
{
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('home', absolute: false).'?verified=1');
}
 
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
 
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
return redirect()->intended(route('home', absolute: false).'?verified=1');
}
}

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

resources/views/layouts/navigation.blade.php:

<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-800" />
</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->value)
<x-nav-link :href="route('companies.index')" :active="request()->routeIs('companies.index')">
{{ __('Companies') }}
</x-nav-link>
@endif
@if(auth()->user()->role_id === \App\Enums\Role::COMPANY_OWNER->value)
<x-nav-link :href="route('companies.users.index', auth()->user()->company_id)" :active="request()->routeIs('companies.users.*')">
{{ __('Administrators') }}
</x-nav-link>
<x-nav-link :href="route('companies.guides.index', auth()->user()->company_id)" :active="request()->routeIs('companies.guides.*')">
{{ __('Guides') }}
</x-nav-link>
<x-nav-link :href="route('companies.activities.index', auth()->user()->company_id)" :active="request()->routeIs('companies.activities.*')">
{{ __('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-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
 
<div class="ml-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</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('register')">
{{ __('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-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</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('dashboard')">
<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::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
</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>


Making Thumbnail for Activity

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:

app/Http/Controllers/CompanyActivityController.php:

use Intervention\Image\ImageManager;
 
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, Activity $activity)
{
$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 $request): string|null
{
if (! $request->hasFile('image')) {
return null;
}
 
$filename = $request->file('image')->store(options: 'activities');
 
$thumb = ImageManager::imagick()->read(Storage::disk('activities')->get($filename))
->scaleDown(274, 274)
->toJpeg()
->toFilePointer();
 
Storage::disk('activities')->put('thumbs/' . $request->file('image')->hashName(), $thumb);
 
return $filename;
}
}

As you can see, we are using a disk called activities for file upload. We need to add this custom disk to the config/filesystems.php.

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 Observer would be a perfect fit.

php artisan make:observer ActivityObserver

app/Models/Activity.php:

use App\Observers\ActivityObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
 
#[ObservedBy(ActivityObserver::class)]
class Activity extends Model
{
// ...
}

app/Observers/ActivityObserver.php:

use App\Models\Activity;
use Illuminate\Support\Facades\Storage;
 
class ActivityObserver
{
public function updating(Activity $activity): void
{
if ($activity->isDirty('photo') && $activity->getOriginal('photo')) {
Storage::disk('activities')->delete($activity->getOriginal('photo'));
Storage::disk('activities')->delete('thumbs/' . $activity->getOriginal('photo'));
}
}
 
public function deleting(Activity $activity): void
{
if ($activity->photo) {
Storage::disk('activities')->delete($activity->photo);
Storage::disk('activities')->delete('thumbs/' . $activity->photo);
}
}
}

After uploading images, we also have thumbnail images in the activites/thumbs directory.


Showing Activities on the Homepage

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.

app/Http/Controllers/HomeController.php:

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

resources/views/home.blade.php:

<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) }}" alt="{{ $activity->name }}">
<h2>
<a href="#" class="text-lg font-semibold">{{ $activity->name }}</a>
</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 file, I used thumbnail for showing thumbnails, but we don't have such a field in the DB. To display the thumbnail, we will use Accessor. Also, if there is no image for the activity, we will show a default image I took from the first Google result.

app/Models/Activity.php:

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 : '/no_image.jpg',
);
}
}

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


Show Activity

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

php artisan make:controller ActivityController

routes/web.php:

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

For the Route, I am using Route Model Binding, meaning we can return a View and pass the activity to it in the Controller.

app/Http/Controllers/ActivityController.php:

use App\Models\Activity;
 
class ActivityController extends Controller
{
public function show(Activity $activity)
{
return view('activities.show', compact('activity'));
}
}

And in the Blade file, we just show all the information for now.

resources/views/activities/show.blade.php:

<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->name }}">
<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.

resources/views/home.blade.php:

// ...
<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->name }}">
</a>
<h2>
<a href="#" class="text-lg font-semibold">{{ $activity->name }}</a>
<a href="{{ route('activity.show', $activity) }}" class="text-lg font-semibold">{{ $activity->name }}</a>
</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:


Seeding Dummy Data

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

php artisan make:seeder ActivitySeeder

database/seeders/ActivitySeeder.php:

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

And we need to call it.

database/seeders/DatabaseSeeder.php:

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

Tests

Before adding tests for the homepage, we need to fix 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.

tests/TestCase.php:

use Database\Seeders\RoleSeeder;
 
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
 
protected bool $seed = true;
protected string $seeder = RoleSeeder::class;
}

Tests also must be adjusted to the home route change.

tests/Feature/Auth/AuthenticationTest.php:

class AuthenticationTest extends TestCase
{
// ...
 
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
 
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
 
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$response->assertRedirect(route('home', absolute: false));
}
 
// ...
}

tests/Feature/Auth/EmailVerificationTest.php:

class EmailVerificationTest extends TestCase
{
// ...
 
public function test_email_can_be_verified(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
 
Event::fake();
 
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
 
$response = $this->actingAs($user)->get($verificationUrl);
 
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
$response->assertRedirect(route('home', absolute: false).'?verified=1');
}
 
// ...
}

tests/Feature/Auth/RegistrationTest.php:

class RegistrationTest extends TestCase
{
// ...
 
public function test_new_users_can_register(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
 
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$response->assertRedirect(route('home', absolute: false));
}
 
public function test_user_can_register_with_token_for_company_owner_role()
{
// ...
 
$response->assertRedirect(route('dashboard', absolute: false));
$response->assertRedirect(route('home', absolute: false));
}
 
public function test_user_can_register_with_token_for_guide_role()
{
// ...
 
$response->assertRedirect(route('dashboard', absolute: false));
$response->assertRedirect(route('home', absolute: false));
}
}

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.

tests/Feature/CompanyActivityTest.php:

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->id]);
$guide = User::factory()->guide()->create();
 
$file = UploadedFile::fake()->image('avatar.jpg');
 
$this->actingAs($user)->post(route('companies.activities.store', $company), [
'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->id]);
$guide = User::factory()->guide()->create();
 
$file = UploadedFile::fake()->create('document.pdf', 2000, 'application/pdf');
 
$response = $this->actingAs($user)->post(route('companies.activities.store', $company), [
'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

tests/Feature/HomePageTest.php:

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(2)]);
 
$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

tests/Feature/ActivityTest.php:

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
avatar

I think we need to remove the image when deleting the activity

I suggest this in destroy method:

public function destroy(Company $company, Activity $activity)
{
    $this->authorize('delete', $company);

    if ($activity->photo) {
        $this->unlinkPhoto(image: $activity->photo, disk: 'activities');
    }

    $activity->delete();

    return to_route('companies.activities.index', $company);
}
private function unlinkPhoto($image, $disk = 'public')
{
    Storage::disk($disk)->delete([$image, 'thumbs/' . $image]);
}
avatar
You can use Markdown
avatar

Hello. You forgot to describe the ActivityObserver file.

avatar
You can use Markdown
avatar

THis has to be outdated with Laravel 10. Ive tried everything to get use Intervention\Image\Facades\Image recognized, and its just not working for the life of me.

avatar

No it is outdated as it was made with laravel 10. Can you be more specific what isn't working? What errors you get?

avatar

Error: Class "Intervention\Image\Facades\Image" not found in /Applications/XAMPP/xamppfiles/htdocs/reservations/app/Http/Controllers/CompanyActivityController.php:105 This happens whenever I run my CompanyActivityTest.Test_can_upload_image wont pass because Image::make in the CompanyActivitiyController isn't recognized.

Ive tried adjusting providers array in app.php, as well as aliases, ive tried php artisan cache:clear, and config, tried php artisan optimize:clear, composer dump-autoload, verifiying its installed, reinstalling it, pretty much everything.

avatar

Can you properly format your comments?

avatar

Well i think i finally solved it. I was installing intervention/image version 3.4. They totally changed how it works if you visit their Github (looking at the README.md: https://github.com/Intervention/image . So I looked at their revision history and tried using version 3.0 , still no dice. I then decided to use version 2.7. That fixed it, although i got a bunch of nasty warnings when i updated my composer.json intervention/image in "require" to 2.7. But that fixed it. I think the syntax might need updating to the modern version that Github reflects, however i could be wrong.

avatar

You are rigth. If you would look at the repository at the time of writing the 2.7 version was used. Maybe this course will get updated with the release of Laravel 11.

avatar
You can use Markdown
avatar

Anyone with php8.3 on Windows, did you managed to pass the "can upload test"? I didn't have imagick enabled, so I embarked on a quest to get it enable, and it looks like is not working with php 8.3 yet.

FAILED Tests\Feature\CompanyActivityTest > can upload image Unable to find a file or directory at path [thumbs/HQEcxv5QkPGmBwXYymJsmMrGSgYw5EIvWKq638wl.jpg]. Imagick PHP extension must be installed to use this driver.

at vendor\laravel\framework\src\Illuminate\Filesystem\FilesystemAdapter.php:119 115▕ 116▕ $paths = Arr::wrap($path); 117▕ 118▕ foreach ($paths as $path) { ➜ 119▕ PHPUnit::assertTrue( 120▕ $this->exists($path), "Unable to find a file or directory at path [{$path}]." 121▕ ); 122▕ 123▕ if (! is_null($content)) {

1 vendor\laravel\framework\src\Illuminate\Filesystem\FilesystemAdapter.php:119 2 tests\Feature\CompanyActivityTest.php:87

Tests: 1 failed, 13 passed (31 assertions) Duration: 4.25s

I do have php 8.1 installed, but since I started the tutorial with php 8.3 I can't switch back and forth. I'm curios if someone in my situation managed to move forward.

Thanks!

avatar

You must enable the extension otherw it won't

avatar

That's a bummer :( . Looks like the support for imagick on php 8.3 still requries some development efforts form php dev team, and I really don't want to start from scratch under a different version.

avatar

I really doubt its the extensions problem. It's how to enable it. And you don't need to start over just because of php version. But laravel 11 requires 8.2 minimum if you are using that version

avatar

Can you expand on the part where I don't need to start over if I switch to php 8.1? I am interested to move forward without having write the whole thing from ground up, but when I tried to switch from php8.3 to php8.1, and atempted to run php artisan serve I had a different error requiring at least php 8.2. The Laravel version I'm using is 10.3.

avatar

Run composer update and you should be fine. But I would suggest figur the 8.3 problem as 8.1 isn't maintained anymore

avatar

I managed to make it work on MacOS with Laravel 10 and php 8.3, now all test are passing with flying colors. I will try your suggestion on Windows tomorrow as I don't access to one from home. Thank you.

avatar

Maan, this was a long journey. There is no official release of imagick for PHP 8.3 due to issues with the build machine (according to their statement on Github) however there are a few nice people that made it their mission to build imagick for PHP 8.3 and can be downloaded form here: https://github.com/Imagick/imagick/issues/573#issuecomment-1827616798 Now I have all test passing on Windows as well.

avatar

@teebee thanks for your link. I made it work on my Windows 10 machine in PHP 8.3.4 with Laragon.

avatar

I knew someone else was bound to run into the same issue :). Glad to hear it helped you.

avatar
You can use Markdown
avatar

If have an issue displaying the thumbnails. I configured filesystems.php as in this lesson, but my files are getting stored in public/storage/activities instead of public/activities. For this reason the images don't show up.

I could fix this by changing the method to:

protected function thumbnail(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->photo ? '/storage/activities/thumbs/' . $this->photo : '/noimage.png',
        );
    }

Or by changing following in the the filesystems.php

'root' => storage_path('app/public/activities')

to

'root' => public_path() . '/activities',
  1. Could there be something wrong and is that the reason why it uses this subfolder storage?
  2. Which fix is the best? I see public_path() only stores the file in the public path in a none linked folder, but not in the storage folder.
avatar

You should not store images in the public folder. Your public folder should have a symlink called storage there (after running php artisan storage:link) and you should access all of your images from /storage/XXX/XXX.png.

Changing your filesystem to write to public path is really bad as that will push all images to GIT, which will cause issues.

avatar

Hi Modestas, thank you for your reply. Writing to the public path seemed indeed as a bad practice. I do have a storage folder in my public folder, but in the code above the activities folder is a direct child of the public folder. Maybe its from an older Laravel version.

avatar

I think that you are misunderstanding things a little bit :)

/public/storage will symlink to /storage/app/public - which means that all the uploads can be accessed publicly.

There's another, private storage, which would write into /storage/app and it would not be accessible via url. It will be uploaded, but there will not be any access from URL endpoint.

So to answer your question - you have to upload all your files to /storage/app/public which will be accessible via domain.com/storage/PATH_TO_FILE

avatar

That was what I meant, but clearly I haven't explained it correctly. We are on the same wavelength. Thank you

avatar
You can use Markdown
avatar
You can use Markdown