In Filament, you may need two-level nested resources, like Courses and Lessons inside of them. Sadly, Filament doesn't have this feature with convenient navigation out of the box yet. But in this tutorial, we will try to build nested resources functionality manually.


First, let's identify the problem we need to solve.
The Problem with Nested Resources
In short, our primary goal is to navigate to the children's records list (lessons) from the parent record (courses) list.
Also, we need to take care of quite a few things that happen next:
- After clicking "New lesson", Filament needs to automatically assign the "parent" course and redirect back to the correct course
- At all times, we must navigate inside the parent resource with URLs like
/courses/X/lessons,/courses/X/lessons/Y, etc.
For example, if we have the Edit form of courses/1/lessons/2, we need to take care that, after submission, the user would be redirected to courses/1/lessons - the list of lessons with the course ID 1.
In other words, we need to have the parent record data at any point when navigating the child record pages to assign the correct parent and redirect to the right pages. By default, your Filament LessonResource will not know anything about the parent CourseResource.
And, as we discovered, it's not easy to override the redirects in Filament. Some functions allow to specify the property values, but others are closed from direct modifications, especially POST requests after submitting the forms.
Now, let me show our solution. Remember, it's a bit experimental, and we're happy to improve it based on your feedback!
Step 1: DB Setup
In this project, we will use a hasMany relationship.
Migrations
Schema::create('courses', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();}); Schema::create('lessons', function (Blueprint $table) { $table->id(); $table->foreignId('course_id')->constrained(); $table->string('title'); $table->text('text'); $table->timestamps();});
Our Models define the relationship as hasMany for Courses and belongsTo for Lessons.
app/Models/Course.php
class Course extends Model{ use HasFactory; protected $fillable = [ 'name' ]; public function lessons(): HasMany { return $this->hasMany(Lesson::class); }}
app/Models/Lesson.php
class Lesson extends Model{ use HasFactory; protected $fillable = [ 'course_id', 'title', 'text', ]; public function course(): BelongsTo { return $this->belongsTo(Course::class); }}
That's it for our DB setup.
Step 2: Creating Two Filament Resources
For our Resource, we used the following commands:
php artisan make:filament-resource Course --generatephp artisan make:filament-resource Lesson --generate
This will create the Filament resources, generating the columns/inputs directly from the DB with the --generate flag.
For now, at this stage, they are not nested yet.
We won't change anything in the CourseResource for now.
app/Filament/Resources/CourseResource.php
class CourseResource extends Resource{ protected static ?string $model = Course::class; protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; public static function form(Form $form): Form { return $form ->schema([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } 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'), ]; }}
Next, in the LessonResource, we did a few modifications:
- Remove the
course_idfield from the form (we will assign it automatically) - Remove the
course.namecolumn from the table (we will show only lessons of a specific course) - Disable the navigation (lessons will be accessible only through the "Courses" menu)
app/Filament/Resources/LessonResource.php
class LessonResource extends Resource{ protected static ?string $model = Lesson::class; protected static bool $shouldRegisterNavigation = false; public static function form(Form $form): Form { return $form ->schema([ // This field was generated, but we will not use it Forms\Components\Select::make('course_id') ->relationship('course', 'name') ->required(), Forms\Components\TextInput::make('title') ->required() ->maxLength(255), Forms\Components\Textarea::make('text') ->required() ->maxLength(65535) ->columnSpanFull(), ]); } public static function table(Table $table): Table { return $table ->columns([ // This column was generated, but we will not use it Tables\Columns\TextColumn::make('course.name') ->numeric() ->sortable(), Tables\Columns\TextColumn::make('title') ->searchable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('updated_at') ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListLessons::route('/'), 'create' => Pages\CreateLesson::route('/create'), 'edit' => Pages\EditLesson::route('/{record}/edit'), ]; }}
Everything else generated is the same as the default Filament Resource (Create, Edit, List, etc.).
Step 3: Implementing the Nested Resource
Here comes the main part of this tutorial. We will have to modify quite a few files, so we will discuss each file and why we changed something. Let's start with creating a new Trait:
Here's what the Trait does:
- It will automatically resolve the parent resource's model and set it to
$this->parent. - It will automatically apply a filter to the table query to only show records with the parent's ID.
- It will automatically generate breadcrumbs for the parent resource and the current resource. For example:
Courses > Filament Example - Nested resources > Lessons > List - It will automatically resolve the relationship key like
course_idfor thelessonstable and set it to$this->relationshipKey.
Notice: there's no Artisan command
make:trait. You need to create that PHP file manually in your IDE.
app/Filament/Traits/HasParentResource.php
use Exception;use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\ModelNotFoundException; /** * @property string|null $relationshipKey Define custom relationship key (if it does not match the table name pattern). * @property string|null $pageNamePrefix Define custom child page name prefix (if it does not match the resource's slug). */trait HasParentResource{ public Model|int|string|null $parent = null; public function bootHasParentResource(): void { // Retrieve the parent resource's model. if ($parent = (request()->route('parent') ?? request()->input('parent'))) { $parentResource = $this->getParentResource(); $this->parent = $parentResource::resolveRecordRouteBinding($parent); if (!$this->parent) { throw new ModelNotFoundException(); } } } public static function getParentResource(): string { $parentResource = static::getResource()::$parentResource; if (!isset($parentResource)) { throw new Exception('Parent resource is not set for '.static::class); } return $parentResource; } protected function applyFiltersToTableQuery(Builder $query): Builder { // Apply any filters before the parent relationship key is applied. $query = parent::applyFiltersToTableQuery($query); return $query->where($this->getParentRelationshipKey(), $this->parent->getKey()); } public function getParentRelationshipKey(): string { // You can set Custom relationship key (if it does not match the table name pattern) via $relationshipKey property. // Otherwise, it will be auto-resolved. return $this->relationshipKey ?? $this->parent?->getForeignKey(); } public function getChildPageNamePrefix(): string { return $this->pageNamePrefix ?? (string) str(static::getResource()::getSlug()) ->replace('/', '.') ->afterLast('.'); } public function getBreadcrumbs(): array { $resource = static::getResource(); $parentResource = static::getParentResource(); $breadcrumbs = [ $parentResource::getUrl() => $parentResource::getBreadCrumb(), $parentResource::getRecordTitle($this->parent), $parentResource::getUrl(name: $this->getChildPageNamePrefix() . '.index', parameters: ['parent' => $this->parent]) => $resource::getBreadCrumb(), ]; if (isset($this->record)) { $breadcrumbs[] = $resource::getRecordTitle($this->record); } $breadcrumbs[] = $this->getBreadCrumb(); return $breadcrumbs; }}
Next, we will modify our CourseResource to add a few things:
- Add record title for the parent resource to be used in breadcrumbs
- Add a link to each row for its nested resources
- Define routes for nested resource
app/Filament/Resources/CourseResource.php
use App\Filament\Resources\LessonResource\Pages\CreateLesson;use App\Filament\Resources\LessonResource\Pages\EditLesson;use App\Filament\Resources\LessonResource\Pages\ListLessons;use Filament\Facades\Filament;use Filament\Tables\Actions\Action;use Illuminate\Contracts\Support\Htmlable;use Illuminate\Database\Eloquent\Model;use Illuminate\Support\Facades\Route; // ... class CourseResource extends Resource{ // ... public static function getRecordTitle(?Model $record): string|null|Htmlable { return $record->name; } public static function table(Table $table): Table { return $table ->columns([ // ... ]) ->filters([ // ... ]) ->actions([ Tables\Actions\EditAction::make(), Action::make('Manage lessons') ->color('success') ->icon('heroicon-m-academic-cap') ->url( fn (Course $record): string => static::getUrl('lessons.index', [ 'parent' => $record->id, ]) ), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } 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'), // Lessons 'lessons.index' => ListLessons::route('/{parent}/lessons'), 'lessons.create' => CreateLesson::route('/{parent}/lessons/create'), 'lessons.edit' => EditLesson::route('/{parent}/lessons/{record}/edit'), ]; }}
Now that our CourseResource is ready, we can move on to our LessonResource. We will do the following:
- Add record title for the child resource to be used in breadcrumbs
- ADd parent resource property
- Remove route definitions (they can stay, but it's not needed)
app/Filament/Resources/LessonResource.php
use Illuminate\Contracts\Support\Htmlable;use Illuminate\Database\Eloquent\Model; // ... class LessonResource extends Resource{ // ... public static string $parentResource = CourseResource::class; public static function getRecordTitle(?Model $record): string|null|Htmlable { return $record->title; } public static function table(Table $table): Table { return $table ->columns([ // ... ]) ->filters([ // ... ]) ->actions([ Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make() ->url( fn (Pages\ListLessons $livewire, Model $record): string => static::$parentResource::getUrl('lessons.edit', [ 'record' => $record, 'parent' => $livewire->parent, ]) ), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } public static function getPages(): array { return [ 'index' => Pages\ListLessons::route('/'), 'create' => Pages\CreateLesson::route('/create'), 'edit' => Pages\EditLesson::route('/{record}/edit'), ]; }}
Now, we will start with our actual pages, starting with the ListLessons page. We will do the following:
- Add the
HasParentResourcetrait from above - Modify headers and create action to use the parent resource's URL
app/Filament/Resources/LessonResource/Pages/ListLessons.php
use App\Filament\Resources\CourseResource;use App\Filament\Traits\HasParentResource; // ... class ListLessons extends ListRecords{ use HasParentResource; protected static string $resource = LessonResource::class; protected function getHeaderActions(): array { return [ Actions\CreateAction::make(), Actions\CreateAction::make() ->url( fn (): string => static::getParentResource()::getUrl('lessons.create', [ 'parent' => $this->parent, ]) ), ]; }}
That's it for our ListLessons page. Now, on the CreateLesson page, we will do the following:
- Add the
HasParentResourcetrait - Modify the redirect URL to use the parent resource's URL
- Mutate the form data to add the parent resource's ID
app/Filament/Resources/LessonResource/Pages/CreateLesson.php
use App\Filament\Resources\CourseResource;use App\Filament\Traits\HasParentResource; // ... class CreateLesson extends CreateRecord{ use HasParentResource; protected static string $resource = LessonResource::class; protected function getRedirectUrl(): string { return $this->previousUrl ?? static::getParentResource()::getUrl('lessons.index', [ 'parent' => $this->parent, ]); } // This can be moved to Trait, but we are keeping it here // to avoid confusion in case you mutate the data yourself protected function mutateFormDataBeforeCreate(array $data): array { // Set the parent relationship key to the parent resource's ID. $data[$this->getParentRelationshipKey()] = $this->parent->id; return $data; }}
And finally, on the EditLesson page, we will do the following:
- Add the
HasParentResourcetrait - Modify the redirect URL to use the parent resource's URL
- Configure the delete action redirects
app/Filament/Resources/LessonResource/Pages/EditLesson.php
use App\Filament\Resources\CourseResource;use App\Filament\Traits\HasParentResource; // ... class EditLesson extends EditRecord{ use HasParentResource; protected static string $resource = LessonResource::class; protected function getHeaderActions(): array { return [ Actions\DeleteAction::make(), ]; } protected function getRedirectUrl(): string { return $this->previousUrl ?? static::getParentResource()::getUrl('lessons.index', [ 'parent' => $this->parent, ]); } protected function configureDeleteAction(Actions\DeleteAction $action): void { $resource = static::getResource(); $action->authorize($resource::canDelete($this->getRecord())) ->successRedirectUrl(static::getParentResource()::getUrl('lessons.index', [ 'parent' => $this->parent, ])); }}
That's it! You should have working nested resources now! Here's how that looks in the admin panel:

This opens up a new page with the list of lessons for the selected course:

And here's the Edit form for the lesson:

Final Words
This is an experimental implementation of nested resources we came up with. We are considering adding this functionality to Filament core or releasing it as a plugin with a more robust solution, so any feedback is appreciated.
You can find the complete code for this tutorial on GitHub and all the changes in this commit
If you are looking for a multi-tenant example, you can find it in this GitHub Branch
Updates
The last revision of this tutorial was made on October 21, 2023. This includes a few updates:
- We have simplified the implementation to use less code and less complexity.
- We have improved the Trait to refer to the resource to get the parent resource.
- There was a removal of
$parentResourceon ourList,Create, andEditpages. - Removed custom URL resolving in our Parent resource
- Allowed Table actions to be moved back to the Resource file instead of the List file.
- Included a tenant code example link.
- This example has a fully working multi-tenancy support for Filament.
This came from our community member who made a PR Guilherme Saade, so big thanks to him!
If you want more Filament examples, you can find more real-life projects on our FilamentExamples.com.
Hello,
I'm using filament3 and looking for neseted resoure for my project.
I follow your tutorial, and when i click for Manage lessons it show me an error "Call to a member function uri() on null".
Hi, can you add more details on the error message itself? Not sure what would be wrong looking at this :)
Dear Mr.Modestas
I'm using filament v3 with laravel-modules, I create resoures under module.
I just download your code to test, it's work fine. So I'm not sure what happen with the code.
It error in this block code
public static function getUrl(string $name = 'index', array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string { $parameters['tenant'] ??= ($tenant ?? Filament::getTenant());
It seems that you are missing something in your setup. Check what might have gone wrong there and double check that you have all the routes defined on parent resource correctly
yes, i will try to check.
Thank you for your kindness. :)