Filament Nested Resources: Manage Courses and their Lessons

Filament Nested Resources: Manage Courses and their Lessons
Admin
Thursday, April 13, 2023 9 mins to read
Share
Filament Nested Resources: Manage Courses and their Lessons

If you have two Resource Controllers like Courses and Lessons, they are often called nested resources in Laravel. In this tutorial, I will show you how to make nested resources in Filament.

For this tutorial we will have two models Course, and Lesson. The course will have many Lessons, and Lessons will belong to a Course.

And this is what we will be building:

  1. In the list of courses, you will see a link to manage lessons of that course
  2. The page for managing lessons will show the title of the course and breadcrumbs including that course title

courses page

lessons page


Prepare Resources

First, we will prepare resources. In the LessonResource, we need to set a new route and change create a route so that it would have a record, change a slug URL, set that it won't be registered in the navigation, and change the query so that it would get lessons only for the selected course.

app/Filament/Resources/LessonResource.php:

class LessonResource extends Resource
{
protected static ?string $model = Lesson::class;
 
protected static ?string $slug = 'courses/lessons';
 
protected static bool $shouldRegisterNavigation = false;
 
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('title')
->required(),
]);
}
 
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
 
public static function getPages(): array
{
return [
'index' => Pages\ListLessons::route('/'),
'lessons' => Pages\ListLessons::route('/{record}'),
'create' => Pages\CreateLesson::route('/{record}/create'),
'edit' => Pages\EditLesson::route('/{record}/edit'),
];
}
 
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->where('course_id', request('record'));
}
}

Now, that we have created a route for listing lessons, we can add an action to the CourseResource to list lessons.

app/Filament/Resources/CourseResource.php:

class CourseResource extends Resource
{
protected static ?string $model = Course::class;
 
protected static ?string $navigationIcon = 'heroicon-o-collection';
 
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('title')
->required(),
]);
}
 
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('lessons_count')
->counts('lessons'),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
])
->prependActions([
Tables\Actions\Action::make('View lessons')
->color('success')
->icon('heroicon-s-view-list')
->url(fn (Course $record): string => LessonResource::getUrl('lessons', ['record' => $record]))
]);
}
 
public static function getRelations(): array
{
return [
//
];
}
 
public static function getPages(): array
{
return [
'index' => Pages\ListCourses::route('/'),
'create' => Pages\CreateCourse::route('/create'),
'edit' => Pages\EditCourse::route('/{record}/edit'),
];
}
}

After creating a couple of courses you should see result like the bellow:

course list page


Creating Lesson

Before creating a lesson, we need to modify the URL of the create action.

app/Filament/Resources/LessonResource/Pages/ListLessons.php:

class ListLessons extends ListRecords
{
protected static string $resource = LessonResource::class;
 
protected function getActions(): array
{
return [
Actions\CreateAction::make()
->url(fn (): string => LessonResource::getUrl('create', ['record' => request('record')])),
];
}
}

There are a couple of ways to set the course_id field. For this tutorial, we will use Livewire fingerprint from the request to get the path. And after successful create will redirect to the lessons list page.

app/Filament/Resources/LessonResource/Pages/CreateLesson.php:

class CreateLesson extends CreateRecord
{
protected static string $resource = LessonResource::class;
 
protected function mutateFormDataBeforeCreate(array $data): array
{
$path = explode('/', request()->fingerprint['path']);
 
$data['course_id'] = $path[3];
 
return $data;
}
 
protected function getRedirectUrl(): string
{
$path = explode('/', request()->fingerprint['path']);
 
return LessonResource::getUrl('lessons', ['record' => $path[3]]);
}
}

Now you should be able to create a lesson.

lesson created


Edit & Delete Lesson

If you would add more lessons and try to edit them, you will get a 404 page. That's because we changed the query for the lessons. We need to get Lesson manually and that can be done by overwriting the resolveRecord() method.

And for the delete actions, it works but redirects to the wrong URL. So we fix it by chaining the after() method to the DeleteAction.

app/Filament/Resources/LessonResource/Pages/EditLesson.php:

class EditLesson extends EditRecord
{
protected static string $resource = LessonResource::class;
 
public Course $course;
 
protected function getActions(): array
{
return [
Actions\DeleteAction::make()
->after(function () {
return $this->redirect($this->getResource()::getUrl('lessons', ['record' => $this->course]));
}),
];
}
 
protected function resolveRecord($key): Model
{
$lesson = Lesson::findOrFail($key);
 
$this->course = $lesson->course;
 
return $lesson;
}
}

And now the edit page and delete action works!

edit lesson page


Breadcrumbs

Now filament shows breadcrumbs for all pages the same way Resource Name / Page. Wouldn't it be cool for lesson pages to show a full breadcrumb with the course name? For this, we need to modify the getBreadcrumbs() method by returning the array [link => title].

Let's do this for listing lessons. First, we need to load the course, in the mount() method, then we will be able to reuse it.

app/Filament/Resources/LessonResource/Pages/ListLessons.php:

class ListLessons extends ListRecords
{
protected static string $resource = LessonResource::class;
 
public Course $course;
 
public function mount(): void
{
parent::mount();
 
$this->course = Course::findOrFail(request('record'));
}
 
protected function getActions(): array
{
return [
CreateAction::make()
->url(fn (): string => LessonResource::getUrl('create', ['record' => request('record')])),
];
}
 
protected function getBreadcrumbs(): array
{
$resource = static::getResource();
 
$breadcrumbs = [
CourseResource::getUrl() => 'Courses',
'#' => $this->course->title,
$resource::getUrl('lessons', ['record' => request('record')]) => $resource::getBreadcrumb(),
];
 
$breadcrumbs[] = $this->getBreadcrumb();
 
return $breadcrumbs;
}
}

Now the difference, before:

before breadcrumbs

And after:

after breadcrumbs

Very similarly we can do this for the Create and Edit pages.

app/Filament/Resources/LessonResource/Pages/CreateLessons.php:

class CreateLesson extends CreateRecord
{
protected static string $resource = LessonResource::class;
 
public Course $course;
 
public function mount(): void
{
parent::mount();
 
$this->course = Course::findOrFail(request('record'));
}
 
protected function mutateFormDataBeforeCreate(array $data): array
{
$path = explode('/', request()->fingerprint['path']);
 
$data['course_id'] = $path[3];
 
return $data;
}
 
protected function getRedirectUrl(): string
{
$path = explode('/', request()->fingerprint['path']);
 
return LessonResource::getUrl('lessons', ['record' => $path[3]]);
}
 
protected function getBreadcrumbs(): array
{
$resource = static::getResource();
 
$breadcrumbs = [
CourseResource::getUrl() => 'Courses',
'#' => $this->course->title,
$resource::getUrl('lessons', ['record' => $this->course]) => $resource::getBreadcrumb(),
];
 
$breadcrumbs[] = $this->getBreadcrumb();
 
return $breadcrumbs;
}
}

app/Filament/Resources/LessonResource/Pages/EditLesson.php:

class EditLesson extends EditRecord
{
protected static string $resource = LessonResource::class;
 
public Course $course;
 
protected function getActions(): array
{
return [
Actions\DeleteAction::make()
->after(function () {
return $this->redirect($this->getRedirectUrl());
}),
];
}
 
protected function resolveRecord($key): Model
{
$lesson = Lesson::findOrFail($key);
 
$this->course = $lesson->course;
 
return $lesson;
}
 
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('lessons', ['record' => $this->course]);
}
 
protected function getBreadcrumbs(): array
{
$resource = static::getResource();
 
$breadcrumbs = [
CourseResource::getUrl() => 'Courses',
'#' => $this->course->title,
$resource::getUrl('edit', ['record' => $this->getRecord()]) => $this->getRecordTitle(),
];
 
$breadcrumbs[] = $this->getBreadcrumb();
 
return $breadcrumbs;
}
}

More Info in the Lessons List

How about if on the lessons list page, the user would see for which course they are browsing? We already have Course in this component, so showing is just using it.

app/Filament/Resources/LessonResource/Pages/ListLessons.php:

class ListLessons extends ListRecords
{
// ...
protected function getSubheading(): string|Htmlable|null
{
return 'Viewing lessons for the course: ' . $this->course->title;
}
}

subheadint for lessons


Better Navigation

Now after you visit the lessons page, the navigation Courses menu doesn't have an active state. This can be easily fixed. But first, we need to hide it in the resource.

class CourseResource extends Resource
{
protected static ?string $model = Course::class;
 
protected static ?string $navigationIcon = 'heroicon-o-collection';
 
protected static bool $shouldRegisterNavigation = false;
// ...
}

Now we need to register the Courses menu item manually in the AppServiceProvider.

app/Providers/AppServiceProvider.php:

<?php
 
namespace App\Providers;
 
use App\Filament\Resources\CourseResource;
use Filament\Facades\Filament;
use Filament\Navigation\NavigationItem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
 
class AppServiceProvider extends ServiceProvider
{
// ...
public function boot(): void
{
Filament::serving(function () {
Filament::registerNavigationItems([
NavigationItem::make('Courses')
->url(CourseResource::getUrl())
->icon('heroicon-o-academic-cap')
->activeIcon('heroicon-s-academic-cap')
->isActiveWhen(fn (): bool => request()->routeIs('filament.resources.courses.*') || request()->routeIs('filament.resources.courses/lessons.*')),
]);
});
}
}

After this, when you visit and page in the Course or Lesson resource, the Courses menu item will be active.


If you want more Filament examples, you can find more real-life projects on our FilamentExamples.com.