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:
- In the list of courses, you will see a link to manage lessons of that course
- The page for managing lessons will show the title of the course and breadcrumbs including that course title
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:
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.
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!
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:
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; } }
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; }}
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.
Really helpful! Thank you Povilas!
I really wish I could save this tutorial, like dev.to
What do you mean by "Save"? You mean "Bookmark"? Then use your browser function for it, should work.
Hey Povilas,
I think he means favourite courses and tutorials lists which can be added to by "hearting" a course or tutorial. That way we could have a favourites page which lists them under our user, regardless of what device we're logged in on.
Adding a favourite or bookmark to the browser is limited to the device you're on unless you have shared these. And it makes it more difficult to find in 6 months time when you're looking for it.
Yeah, I see your point, James. But there are external tools for bookmarks like Pocket, and browsers can be synced between devices.
But I'll think about where we can fit this function into our schedule, for now we're focused on creating content and, well, a bit of Summer vacations :)
Thanks for this <3
When trying a sort a Column on the Lessons Table, it throws the following error: How could i get it back to be able to sort columns again ?
To solve your error after you press sort you don't have record from the request. To get it simply you can you do it like so
But unfortunately it does not solve sorting problem 100%. It then just gives no records but after page refresh it works. And sorry, don't know how to fix it. Also, while writing this tutorial package filament-nested-resources was released. It's a wip package but you could try using it. Or try official filament discord to get help, maybe someone from the core team will help sove this.
thx, this thing worked for me: request('record') ?? explode('/', request()->fingerprint['path'])[3]
If you would like the link for the course in the breadcrumbs to go to the course's edit page, you can simply change the second breadcrumb in the array from:
to:
very helpful, Thank you Povilas!
One issue right now i am facing is while deleting any child detail, it was throwing the error: Missing required parameter for [Route: filament.resources.lookups/lookupDetails.lookupDetails] [URI: admin/lookups/lookupDetails/{record}] [Missing parameter: record].
but then I saw the comment of Nerijus, and implemented the same, now instead of throwing error, the pop up for delete appears and it also give a success message but the record is not deleting. Further to this, the sorting is not working on child page and it shows empty page.
request('record') ?? explode('/', request()->fingerprint['path'])[3]
Great article. I'm getting following error when clicking on the "Create new lesson" button though.
Target Illuminate\Database\Eloquent\Model] is not instantiable.
Edit and Delete are just working fine. No idea whats causing this error message. Filament version I'm using is v3 though, not sure if it's related to this. Any ideas anyone?
+1
In Filament v3, this tutorial doesn't work, unfortunately. In general, nested resources are not supported in Filament, you can do it in a "hacky" way but probably shouldn't.
Running in to the same issue.. What do you guys suggest as the most robust and stable solution? Can't get my head around it..
What I've actually done is creating a custom and edit page and registering it within the childs resource within the method getPages().
Also added mount and getEloquentQuery method into childs resource to get the correct childs and make the filter of the table functioning within the requested parent. If not it would just display all children from all parents.
For the custom pages, they have a getFormSchema, mount and submit method which will handle the required actions for me.
I might not be a good method but it worked for us :)
We've found a solution, and we are working on the update to this tutorial for Filament 3, should be released in 1-2 weeks, currently in "testing" phase :)
Thanks for the heads-up guys!