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.

avatar

Really helpful! Thank you Povilas!

avatar
You can use Markdown
avatar

I really wish I could save this tutorial, like dev.to

avatar

What do you mean by "Save"? You mean "Bookmark"? Then use your browser function for it, should work.

avatar

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.

avatar

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 :)

avatar
You can use Markdown
avatar

Thanks for this <3

avatar
You can use Markdown
avatar

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 ?

Missing required parameter for [Route: filament.resources.courses/lessons.lessons] [URI: admin/courses/lessons/{record}] [Missing parameter: record].

avatar

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

request('record') ?? explode('/', request()->fingerprint['path'])[3]

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.

avatar

thx, this thing worked for me: request('record') ?? explode('/', request()->fingerprint['path'])[3]

avatar
You can use Markdown
avatar

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:

'#' => $this->course->title,

to:

CourseResource::getUrl('edit', ['record' => $this->course]) => $this->course->title,
👍 1
avatar
You can use Markdown
avatar

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]

avatar
You can use Markdown
avatar

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?

avatar

+1

avatar

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.

avatar

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..

avatar

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.

public function mount()
    {
        $this->currentUrl = url()->current();
    }
		
public static function getEloquentQuery(): Builder
    {
        $path = explode('/', url()->current());

        if ($path[4] == 'trend-detections' && isset($path[5])) {
            Cache::set('filter_location_id', $path[5]);
        }

        return parent::getEloquentQuery()->where('location_id', Cache::get('filter_location_id'));
    }

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 :)

avatar

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 :)

avatar

Thanks for the heads-up guys!

avatar
You can use Markdown
avatar
You can use Markdown

Recent New Courses