Filament v3 Nested Resources: Solution with Trait and Pages

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 --generate
php 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 the lessons 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 our List, Create, and Edit 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.

avatar

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

avatar

Hi, can you add more details on the error message itself? Not sure what would be wrong looking at this :)

avatar

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());

    $routeBaseName = static::getRouteBaseName(panel: $panel);
    $routeFullName = "{$routeBaseName}.{$name}";
			
			
    $routePath = Route::getRoutes()->getByName($routeFullName)->uri(); //This line throw error

    if (str($routePath)->contains('{parent}')) {
        $parameters['parent'] ??= (request()->route('parent') ?? request()->input('parent'));
    }

    return route($routeFullName, $parameters, $isAbsolute);
}
avatar

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

avatar

yes, i will try to check.

Thank you for your kindness. :)

avatar

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

avatar

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!

avatar

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.

avatar

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!

avatar

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

avatar

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)

avatar

I think this is a bug on Filament side. The anonymous function should reflect the return types of the getRecordTitle function.

avatar

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 )

avatar

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!

avatar

Updated the article with new code! Please check if that solves your issue (new implementation and removed a lot of repeating code)

avatar

Cool, it's working now with the new code, thanks a lot!!

avatar

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

avatar

It seems that you forgot to add the CreateResource code to add the parent information:

 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;
    }
avatar

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

avatar

I think you have some issues with the code on your end.

I just tries to add empty state action:

->emptyStateActions([
		Action::make('create')
				->label('Create post')
				->url(function (Pages\ListLessons $livewire) {
						return static::$parentResource::getUrl('lessons.create', [
								'parent' => $livewire->parent,
						]);
				})
				->icon('heroicon-m-plus')
				->button(),
]);

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

avatar

don't know what's happening, I just put your code and this happened

https://flareapp.io/share/LPlK1bnm

avatar

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

avatar

perfect, now is working! thank you!!

avatar

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

avatar

currently this is not possible. it was designed for 2 levels only, as 3 and more will require a lot more code modifications.

Sorry

avatar

Anyway, thank you for your hard work.

avatar

@Phanith Could you solve this for 3 levels?

avatar

@Nils Domin, I can create unlimited nested resources in filament using nested resource filament plugin by Guava.

additionally, I just simply modify its breadcrumbs function to match my need. but the plugin itself is great and working.

avatar

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:

protected function applyFiltersToTableQuery(Builder $query): Builder
{
    return $query->where($this->getParentRelationshipKey(), $this->parent->getKey());
}

Seems like it by pass the parent's method. Fixed the filter issue by adding parent call:

protected function applyFiltersToTableQuery(Builder $query): Builder
{
    $query = parent::applyFiltersToTableQuery($query);
    return $query->where($this->getParentRelationshipKey(), $this->parent->getKey());
}
avatar

Good catch! Thank you, updating the example!

avatar

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:

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\CourseResource\Pages;
use App\Filament\Resources\CourseResource\RelationManagers;
use App\Models\Course;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Forms\Components\ColorPicker;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
// add course lessons
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;
use Illuminate\Support\Str;
use Filament\Forms\Set;
use Filament\Forms\Components\RichEditor;


class CourseResource extends Resource
{
    protected static ?string $model = Course::class;

    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';

    public static function getRecordTitle(?Model $record): string|null|Htmlable
    {
        return $record->name;
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('title')
                ->required()
                ->reactive()
                ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state))),
                Forms\Components\TextInput::make('slug')->required()->readOnly(),
                Forms\Components\RichEditor::make('description')
                    ->columnSpanFull(),
                Forms\Components\TextInput::make('old_price')
                    // ->lt('new_price')
                    ->numeric(),
                Forms\Components\TextInput::make('new_price')
                    ->lt('old_price')
                    ->numeric(),
                Forms\Components\Select::make('category_id')
                ->relationship('category', 'name')
                ->required(),
                Forms\Components\ColorPicker::make('color'),
                Forms\Components\Toggle::make('published')->default(true),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('title')
                    ->searchable(),
                Tables\Columns\TextColumn::make('slug')
                    ->searchable(),
                Tables\Columns\IconColumn::make('published')
                    ->boolean(),
                ........
            ])
            ->filters([
                //
            ])
            ->actions([
                Tables\Actions\ViewAction::make(),

                Tables\Actions\EditAction::make(),
                
                Action::make('Manage lessons')
                    ->color('success')
avatar

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

avatar

I don't have view page, but the problem was in this line

public static function getRecordTitle(?Model $record): string|null|Htmlable
    {
        return $record->name;
    }

the correct in my case is title instead of name.

avatar

I got confused by viewQction then. But happy you solved it!

avatar

Thanks for your time

avatar

It also works for me, since I also generated the resource --simple

avatar

How to redirect to course lessons page after create in this example?

avatar

You can modify the CreateXXX redirect:

protected function getRedirectUrl(): string
{
		return $this->previousUrl ?? static::getParentResource()::getUrl('lessons.index', [
				'parent' => $this->parent,
		]);
}

And in there you can change where it redirects.

avatar

the question in another way: After i create a course i need to redirect to the manage lessons page

avatar

So you do the redirect in create course file, to this URL

avatar

It will need the id of the parent , how to get it?

avatar

$this->id ???

avatar

$this->record->id

avatar

Thanks

avatar
Diógenes Konrad Götz

How can I use slideOver() on child pages?

protected function getHeaderActions(): array
    {
        return [
            Actions\CreateAction::make()
                ->url(
                    fn (): string => static::getParentResource()::getUrl('menu-sections.create', [
                        'parent' => $this->parent,
                    ])
                )->slideOver(),
        ];
    }
avatar

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:

public static function getPages(): array
	{
			return [
					'index' => Pages\ListLessons::route('/'),
//            'create' => Pages\CreateLesson::route('/create'),
//            'edit' => Pages\EditLesson::route('/{record}/edit'),
			];
	}

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 slideOvers

avatar

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

avatar
<?php
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use App\Filament\Resources\ReportResource;
use App\Filament\Traits\HasParentResource;
class CreateTask extends CreateRecord //here error 
avatar
Trait "App\Filament\Traits\HasParentResource" not found #this error messages
avatar

sorry solved, by adding

namespace App\Filament\Traits;

at first line

avatar

but i have another error.

Call to undefined method App\Models\Report::resolveRecordRouteBinding()

This my Models

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Report extends Model
{
    use HasFactory;

    protected static function booted()
    {
        static::creating(function ($report) {
            $report->user_id = auth()->id();
        });
    }
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

}

avatar

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)

avatar
Diógenes Konrad Götz

You must have set the model in $parentResource and not the resource.

class LessonResource extends Resource
{
    // ...
 
    public static string $parentResource = CourseResource::class; 
avatar

I'm getting SQLSTATE[HY000]: General error: 1364 Field 'course_id' doesn't have a default value

<?php

namespace App\Filament\Resources\ModuleResource\Pages;

use App\Filament\Resources\ModuleResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use App\Filament\Resources\CourseResource;
use App\Filament\Traits\HasParentResource;

class CreateModule extends CreateRecord
{
    use HasParentResource;

    protected static string $resource = ModuleResource::class;

    protected function getRedirectUrl(): string
    {
        return $this->previousUrl ?? static::getParentResource()::getUrl('modules.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;
    }
}
avatar

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!

avatar

Hi. It seems that my problem was here:

            Actions\CreateAction::make()
                ->url(
                    fn (): string => static::getParentResource()::getUrl('lessons.create', [
                        'parent' => $this->parent,
                    ])
                ),

I copied that from the guide. I've now updated it to:

    protected function getHeaderActions(): array
    {
        return [
            Actions\CreateAction::make()
                ->label('Create Lesson')
                ->url(function (ListLessons $livewire) {
                    return static::$parentResource::getUrl('lessons.create', [
                        'parent' => $livewire->parent,
                    ]);
                })
        ];
    }
	
The error I'm getting now is:
		
SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`spcorp`.`lessons`, CONSTRAINT `lessons_module_id_foreign` FOREIGN KEY (`module_id`) REFERENCES `modules` (`id`) ON DELETE CASCADE)
		
		
avatar

Do you have parentResource set on your child resources?

avatar

Yes

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,
        ]);
    }

    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;
    }
}
avatar

I meant on the LessonResource, not on the lesson page. We have had a few updates to this article to make it more reliable

avatar

I do, this is my lesson resource:

class LessonResource extends Resource
{
    public static string $parentResource = ModuleResource::class;

    protected static ?string $model = Lesson::class;

    protected static bool $shouldRegisterNavigation = false;

    public static function getRecordTitle(?Model $record): string|null|Htmlable
    {
        return $record->title;
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('sequence')
                    ->required()
                    ->numeric()
                    ->default(1),
                Forms\Components\TextInput::make('title')
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('video')
                    ->required()
                    ->maxLength(255),
                Forms\Components\RichEditor::make('synopsis')
                    ->columnSpanFull(),
                Forms\Components\TextInput::make('duration')
                    ->nullable()
                    ->numeric(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('sequence')
                    ->numeric()
                    ->sortable(),
                Tables\Columns\TextColumn::make('title')
                    ->searchable(),
                Tables\Columns\TextColumn::make('video')
                    ->searchable(),
                Tables\Columns\TextColumn::make('duration')
                    ->numeric()
                    ->sortable(),
                Tables\Columns\TextColumn::make('module.title')
                    ->numeric()
                    ->sortable(),
            ])
            ->filters([
                //
            ])
            ->actions([
                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(),
                ]),
            ])
            ->emptyStateActions([
                Tables\Actions\CreateAction::make(),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }
    public static function getPages(): array
    {
        return [
            'index' => Pages\ListLessons::route('/'),
            'create' => Pages\CreateLesson::r
avatar

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

avatar

Symfony \ Component \Routing \ Exception \ RouteNotFoundException PHP 8.1.10 Laravel 10.21.0 Route [filament.app.resources.answers.index] not defined.

https://prnt.sc/FGvrbDZXG1SU

avatar

It seems like your route for answer should be filament.app.resources.questions.answers.index. You're missing the word question.

avatar

I followed this tutorial, while I used filament tenancy, where I can fix the route

avatar

Well, it's hard to say because I don't know what page you're visiting to get this error. Share the code for the page.

avatar

Visit Panel Tenancy https://prnt.sc/KiQu8qAFSi4f

github https://github.com/firmanms/filament2/tree/nested

avatar

You need to share the code of the page. There's a link that's pointing to the wrong route. I can't point it out unless I see your code. Your screenshot only shows that you have a route that's not pointed correctly.

avatar

i am trying to create a FAQ module

https://github.com/firmanms/filament2/blob/nested/database/migrations/2023_12_14_005730_create_questions_table.php

https://github.com/firmanms/filament2/blob/nested/database/migrations/2023_12_14_005739_create_answers_table.php

https://github.com/firmanms/filament2/blob/nested/app/Filament/App/Traits/HasParentResource.php

https://github.com/firmanms/filament2/blob/nested/app/Filament/App/Resources/QuestionResource.php

https://github.com/firmanms/filament2/blob/nested/app/Filament/App/Resources/AnswerResource.php

https://github.com/firmanms/filament2/blob/nested/app/Filament/App/Resources/AnswerResource/Pages/ListAnswers.php

https://github.com/firmanms/filament2/blob/nested/app/Filament/App/Resources/AnswerResource/Pages/CreateAnswer.php

https://github.com/firmanms/filament2/blob/nested/app/Filament/App/Resources/AnswerResource/Pages/EditAnswer.php

avatar

out of title post, i have question about crash style panel filament, any solution ? https://prnt.sc/f4HvWJM4A5Ex

avatar

I want to access data from the parent on the create page of the child. I couldn't find a way to get this parent id accessible in the childs resource. Does any of you have a solution for this.

avatar

It should be accessible via $this->parent->id if I'm not mistaken

avatar

Yes, that is the case if I want to access it on the page. However, I am not able to pass it to the resource, so I put information from the parent into a placeholder

avatar

Do you have a demo of this somewhere? I did something similar and it worked without too much issues

avatar

Within LessonResource:

class LessonResource extends Resource
{
	use HasParentResource;
    protected static ?string $model = Lesson::class;
    protected static bool $shouldRegisterNavigation = false;
    public static string $parentResource = CourseResource::class; 
	 
    public static function getRecordTitle(?Model $record): string|null|Htmlable
    {
        return $record->title;
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
               Forms\Components\Placeholder::make('Titel')->content(
				  function() {
			         return $this->parent->title;
			   	  }),
		  ])
	}								

This throws the following error: Using $this when not in object context

avatar

You are right, there is a bit of a different workaround there. Add this to your Lesson resource:

class LessonResource extends Resource
{
    public static string $parentResource = CourseResource::class;

    protected static ?string $model = Lesson::class;

    protected static bool $shouldRegisterNavigation = false;

    public static function getParentInfo()
    {
        if ($parent = (request()->route('parent') ?? request()->input('parent'))) {
            $parentResource = self::$parentResource;

            return $parentResource::resolveRecordRouteBinding($parent);
        }
    }

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Placeholder::make('Titel')->content(
                    function () {
                        dd(self::getParentInfo());
                        return $this->parent->title;
                    }),

The static function getParentInfo() will load a model for you :)

avatar

Thanks a lot for this solution. You have a small inconsistency in your code:

dd(self::getParentInfo());
return $this->parent->title;

should be:

return self::getParentInfo()->title;
avatar

There is still something wrong when I also use a live() element in the resource. After a toggle, an error showed up on the return line: Attempt to read property "title" on null

avatar

Whoops! Sorry about the mistake, indeed I left it by accident (a bit of experimenting gone wrong haha).

Hmm, with the ->live() it could for sure cause some issues. Here, I don't have a direct answer as this whole thing is pretty much a hack. So at this point, can't help you much as it needs investigation and probably more code modifications, sorry!

avatar

After some thinkering around, I found a solution which is also kind of a hack. The Post request generated by the ->live(), also contains a snapshot, where the parent variable can be retrieved:

public static function getParentInfo()
    {
        if ($parent = (request()->route('parent') ?? request()->input('parent'))) {
            $parentResource = self::$parentResource;
            return $parentResource::resolveRecordRouteBinding($parent);
        }elseif(request()->isMethod('post')){
			$req = request()->input("components");
			$snapshot = json_decode($req[0]["snapshot"],true);
			$parent = $snapshot["data"]["parent"][1]["key"];	
			$parentResource = self::$parentResource;
            return $parentResource::resolveRecordRouteBinding($parent);		
		}
 }
avatar

Awesome! This seems like a nice approach :)

avatar

Quick question... After setting up a Nested Resource as per your guide, my 'afterSave' actions on the on the Edit page stopped working. Same code works on non nested resources fine... Any suggestions welcome. Thanks.

avatar

Could you give an example of what stopped working? It should not have impacted any methods, but it may have conflicts with the code itself. So if you could share an example action and error message - that would help.

avatar

I had an afterSave action to simple show a notification of the 'id', running when a record was edited.

After I impliented it as a nested resource, it doesn't trigger.

protected function afterSave(): void
{
$record = $this->record;
    Notification::make()
        ->title($record->id)
        ->success()
        ->send();
}
avatar

Hmm, I'm not sure why this would be the case. I would try to add dumps in multiple places to see why it doesn't get triggered.

We did not touch any of these methods so it's weird that they stopped working and calling hooks :/

avatar

Thank you for your replies.

I'll report back what I discover.

avatar

The list page works as in the tutorial but when I use it with getRecordSubNavigation, it gives this error. Kindly help. 😢

public static function getRecordSubNavigation(Page $page): array
    {
        return $page->generateNavigationItems([
// error from navigationItem::url() on being included here
])
    }

I tried the url() in the navigationItem for a page class. right now they are set to static and cannot be overridden in a ListRecord class. trying to override NavigationItem gives the error

    public static function getNavigationItems(array $urlParameters = []): array

    {

        return [

            NavigationItem::make(static::getNavigationLabel())

                ->group(static::getNavigationGroup())

                ->parentItem(static::getNavigationParentItem())

                ->icon(static::getNavigationIcon())

                ->activeIcon(static::getActiveNavigationIcon())

                // ->isActiveWhen(fn (): bool => request()->routeIs(static::getRouteName()))

                ->sort(static::getNavigationSort())

                ->badge(static::getNavigationBadge(), color: static::getNavigationBadgeColor())

                ->url(fn (): string => EventsResource::getUrl('bookings.index', ['parent' => $this->parent])),

        ];
    }
avatar

Sorry, I can't help you with sub navigation as I haven't used nested resources with it... This one you will have to debug and see why it happens

avatar

I have debugged and found that the issue is coming from NavigationItem isActiveWhen() and Url() as they try to resolve the url from their own resource instead of the parent resource.

I am unable to find a solution though as getNavigationItems is static and we cannot override the url using $this->parent.

avatar

Can I make more than 2 nested resources with this approach?

avatar

this only supports 1 level of nesting. Going deeper is a lot harder

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 59 courses (1057 lessons, total 42 h 44 min)
  • 79 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent Premium Tutorials