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:
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:
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.
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!
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:
And after:
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; } }
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; }}
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.