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_id
field from the form (we will assign it automatically) - Remove the
course.name
column 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_id
for thelessons
table 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
HasParentResource
trait 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
HasParentResource
trait - 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
HasParentResource
trait - 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
$parentResource
on ourList
,Create
, andEdit
pages. - 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. :)
Awesome!!! You're the best.
I have only one point, I am getting error when try to delete a lesson.
Filament\Resources\Pages\ListRecords::Filament\Resources\Pages{closure}(): Return value must be of type string, null returned
I just figured it out, now it work fine. In List Detail Record
// You can set the Custom relationship key (if it does not match the table name pattern) via the $relationshipKey property.
// Otherwise, it will be auto-resolved.
public function getParentRelationshipKey(): string { return $this->relationshipKey = "your_foreign_key"; }
In DetailResource
//Return string the same url you create in MasterResource
public static function getPluralModelLabel(): string { return __('details'); }
I hope it will help you!
This may also happen if your getRecordTitle function returns a null value. Remove the getRecordTitle function from your child resource to check if it solves the issue.
But that function technically accepts the
null
return. So it's a bit weird that it would be like this.Then again, maybe there is another issue with the code itself.
ps. This issue is in theory not existing with the latest code changes. Or at least, we did not encounter it nor we heard it happen again!
@Modestas https://github.com/filamentphp/filament/blob/8b1b5ee045d93f2423295e8110c8bff65739110a/packages/panels/src/Resources/Pages/ListRecords.php#L271 The anonymous function on this line returns a string, which restricts the getRecordTitle function to returning only strings. This is why it fails when the function returns null.
Oh, that makes sense! I probably overlooked that there. So then this function should return a string in any case. Will see what can be done (but honestly, I did not encounter this issue myself yet)
I think this is a bug on Filament side. The anonymous function should reflect the return types of the getRecordTitle function.
Hey guys, don't really know what I'm missing... after do everything you did in the guide, I'm getting a Route [filament.admin.resources.variations.index] not defined.
(I'm trying to replicate this with Services and Variations )
Hi, some code or debugging log would be awesome here!
But my main concern is that some routes are not registered correctly, or there is a method missing (like get url or breadcrumbs).
ps. We will be updating this article soon with improved and reduced code, you can already check that in repository!
Updated the article with new code! Please check if that solves your issue (new implementation and removed a lot of repeating code)
Cool, it's working now with the new code, thanks a lot!!
Hi again, for some reason it's not autopopulating the Parent Relationship Key even if I put manually the foreing key inside the getParentRelationshipKey function... any ideas ? https://flareapp.io/share/omwKOqWm
It seems that you forgot to add the
CreateResource
code to add the parent information:I have it there, but one weird thing I see is happening when I use the Create button when the table is Empty -> "emptyStateActions" and also is happening on delete button attached to each row, it's throwing an error "Filament\Resources\Pages\ListRecords::Filament\Resources\Pages{closure}(): Return value must be of type string, null returned" but when I use the delete button inside the record, it works fine...
I think you have some issues with the code on your end.
I just tries to add empty state action:
And it worked perfectly. Same with Delete button - it did work on both inside the table and inside the records.
Sadly, withou the actual code - there is not much I can help you as it is working just fine on our end...
don't know what's happening, I just put your code and this happened
https://flareapp.io/share/LPlK1bnm
Your namespace seems to be incorrect. If you don't know the exact one it should be - just remove yhe property type (pages\CreateVariation) and leave only the variable livewire
perfect, now is working! thank you!!
It is working fine for 2 levels of nested resources, but I'm encountering errors when attempting to add a third level of nested resources. Is it possible to make it work with 3 levels using your solutions?
Course 2. Lessons 3. Videos I'm receiving an error stating 'Missing required parameter for [Route: filament.admin.resources.lessons.videos.index] [URI: admin/lessons/{parent}/videos] [Missing parameter: parent].'
currently this is not possible. it was designed for 2 levels only, as 3 and more will require a lot more code modifications.
Sorry
Anyway, thank you for your hard work.
Hi, I found that the filter is no longer working after use the nested resource. Tested out a while and found this block of code is the the problem:
Seems like it by pass the parent's method. Fixed the filter issue by adding parent call:
Good catch! Thank you, updating the example!
when i tried to add Tables\Actions\ViewAction::make(), to the table, i got the error message:
error: Filament\Resources\Pages\ListRecords::Filament\Resources\Pages{closure}(): Return value must be of type string, null returned
here is my resource code:
Hi, you need to add the HasParent trait to your view page too. We did not do that, but it should follow the exact same setup as we have with our List/Create/Edit pages
I don't have view page, but the problem was in this line
the correct in my case is title instead of name.
I got confused by viewQction then. But happy you solved it!
Thanks for your time
How to redirect to course lessons page after create in this example?
You can modify the
CreateXXX
redirect:And in there you can change where it redirects.
the question in another way: After i create a course i need to redirect to the manage lessons page
So you do the redirect in create course file, to this URL
It will need the id of the parent , how to get it?
$this->id ???
$this->record->id
Thanks
How can I use slideOver() on child pages?
This is not really related to the lesson here, but you need a very basic resource. That resource can't register create route in order for slideOver to work correctly. For example, on LessonResource:
Commenting out edit/create routes - makes the slide over work.
ps. Comment out the
url()
on your create button - it is not needed if you are using slideOversHi. i tried, but facing error like this. Trait "App\Filament\Traits\HasParentResource" not found
Filament\Resources\TaskResource\Pages\CreateTask.php : 11
this line 1-11
sorry solved, by adding
at first line
but i have another error.
This my Models
Can you add more details about the error you are facing? This can be many things (and probably is just a misconfiguration/syntax error somewhere)
You must have set the model in $parentResource and not the resource.
I'm getting SQLSTATE[HY000]: General error: 1364 Field 'course_id' doesn't have a default value
Please dump what the
getParentRelationshipKey
function returns. There is some kind of misconfiguration on your end as this is a module, and for whatever reason it takes course_id.Or in other words - the code you added does not tell me what you were trying to do, so I can't help effectively. Sorry!
Hi. It seems that my problem was here:
I copied that from the guide. I've now updated it to:
Do you have
parentResource
set on your child resources?Yes
I meant on the LessonResource, not on the lesson page. We have had a few updates to this article to make it more reliable
I do, this is my lesson resource:
Hmm, in that case - there is something off with Database setup. Sorry, really hard to help here. If you want us to better look at the issue - get in touch via email and provide the repository with your code - we can take a look and see what the problem might be :)