laravellaravel-livewirelaravel-filamentfilamentphp

How to Create Nested Resources in Filament 3


I have an event management system. Each event has speakers or sponsors. Here, I implement nested resources for Speakers.

When I try to delete a speaker record via the speakers page, I get an error like below.

Filament\Resources\Pages\ListRecords::Filament\Resources\Pages{closure}(): Return value must be of type string, null returned

https://ibb.co.com/RkyL5Y51

However, when I delete the record via the edit page, it works without any error.

Here are my codes

EventResource.php

<?php

namespace App\Filament\Resources;

use Filament\Forms;
use Filament\Tables;
use App\Models\Event;
use App\Models\Speaker;
use App\Models\Sponsor;
use Filament\Forms\Get;
use App\Models\Currency;
use Filament\Forms\Form;
use Filament\Tables\Table;
use Pages\CreateEventSponsor;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Wizard;
use Illuminate\Support\Facades\Route;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Eloquent\Model;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Actions\ActionGroup;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\TimePicker;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Contracts\Support\Htmlable;
use Guava\FilamentNestedResources\Ancestor;
use App\Filament\Resources\EventResource\Pages;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use App\Filament\Resources\EventResource\RelationManagers;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Guava\FilamentNestedResources\Concerns\NestedResource;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use App\Filament\Resources\SpeakerResource\Pages\EditSpeaker;
use App\Filament\Resources\SponsorResource\Pages\EditSponsor;
use App\Filament\Resources\SpeakerResource\Pages\ListSpeakers;
use App\Filament\Resources\SponsorResource\Pages\ListSponsors;
use App\Filament\Resources\SpeakerResource\Pages\CreateSpeaker;
use App\Filament\Resources\SponsorResource\Pages\CreateSponsor;
use App\Filament\Resources\RegistrantResource\Pages\EditRegistrant;
use App\Filament\Resources\RegistrantResource\Pages\ListRegistrants;
use App\Filament\Resources\EventResource\RelationManagers\SponsorsRelationManager;

class EventResource extends Resource
{
    protected static ?string $model = Event::class;

    protected static ?string $navigationIcon = 'heroicon-o-calendar-days';

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

    public static function form(Form $form): Form
    {
        return $form
            // ->columns(4)
            ->schema([
                Wizard::make([
                    Wizard\Step::make('Detail')
                        ->columns(4)
                        ->schema([
                        SpatieMediaLibraryFileUpload::make('cover')
                            ->required()
                            ->columnSpanFull()
                            ->collection('events')
                            ->responsiveImages()
                            ->conversion('thumb')
                            ->conversionsDisk('public')
                            ->uploadingMessage('Uploading ...'),
                        Forms\Components\TextInput::make('name')
                            ->required()
                            ->maxLength(255)
                            ->columnSpan(2),
                        Forms\Components\DatePicker::make('start_date')
                            ->required()
                            ->native(false)
                            ->columnSpan(1),
                        Forms\Components\DatePicker::make('end_date')
                            ->native(false)    
                            ->columnSpan(1),
                        Forms\Components\Select::make('category_id')
                            ->relationship('category', 'name')
                            ->columnSpan(1)
                            ->required()
                            ->createOptionForm([
                                Forms\Components\TextInput::make('name')
                                    ->required(),
                            ]),
                        Forms\Components\TextInput::make('location')
                            ->maxLength(255)
                            ->columnSpan(2),
                        Forms\Components\TextInput::make('attendance')
                            ->integer()
                            ->columnSpan(1),
                        Forms\Components\RichEditor::make('toc')
                            ->label('Terms & Conditions')
                            ->columnSpanFull(),
                        Forms\Components\Select::make('tags')
                            ->multiple()
                            ->relationship(titleAttribute: 'name')
                            ->createOptionForm([
                                Forms\Components\TextInput::make('name')
                                    ->required(),
                            ]),
                        Forms\Components\Select::make('is_featured')
                            ->options([
                                0 => 'No',
                                1 => 'Yes',
                            ])
                            ->native(false)
                            ->live()
                            ->columnSpan(1),
                        Forms\Components\TextInput::make('button_text')
                            ->maxLength(255)
                            ->hidden(fn (Get $get): bool => ! $get('is_featured'))
                            ->columnSpan(1),
                        Forms\Components\TextInput::make('button_link')
                            ->maxLength(255)
                            ->hidden(fn (Get $get): bool => ! $get('is_featured'))
                            ->columnSpan(1),
                        Select::make('is_registration_active')
                            ->options([
                                0 => 'No',
                                1 => 'Yes',
                            ])
                            ->label('Registration')
                        ]),
                    Wizard\Step::make('Key Topics')
                        ->schema([
                            Repeater::make('keyTopics')
                            ->relationship()
                            ->schema([
                                SpatieMediaLibraryFileUpload::make('icon')
                                    ->columnSpanFull()
                                    ->collection('key-topics')
                                    // ->responsiveImages()
                                    // ->conversion('thumb')
                                    ->conversionsDisk('public')
                                    ->uploadingMessage('Uploading ...')
                                    ->helperText('Download icons at svgrepo.com.'),
                                TextInput::make('title')
                                    ->required()
                                    ->maxLength(255)
                                    ->columnSpanFull(),
                                RichEditor::make('description')
                                    ->required()
                                    ->columnSpanFull(),
                            ])
                            ->columns(2)
                            ->columnSpanFull(),
                        ]),
                    Wizard\Step::make('Agenda')
                        ->schema([
                            Repeater::make('agenda')
                            ->relationship()
                            ->schema([
                                TextInput::make('title')
                                    ->required()
                                    ->maxLength(255)
                                    ->columnSpanFull(),
                                Repeater::make('agendaItems')
                                ->relationship()
                                ->schema([
                                    Forms\Components\TimePicker::make('start_time')
                                        ->native(false)
                                        ->seconds(false)
                                        ->required()
                                        ->columnSpan(1),
                                    Forms\Components\TimePicker::make('end_time')
                                        ->native(false)
                                        ->seconds(false)
                                        ->required()
                                        ->columnSpan(1),
                                    TextInput::make('title')
                                        ->required()
                                        ->maxLength(255)
                                        ->columnSpan(2),
                                    Textarea::make('description')
                                        ->columnSpanFull(),
                                ])
                                ->columns(4)
                            ])
                        ]), 
                    Wizard\Step::make('About')
                        ->schema([
                            SpatieMediaLibraryFileUpload::make('picture1')
                                ->required()
                                ->label('Picture 1')
                                ->columnSpanFull()
                                ->collection('about-event-1')
                                ->responsiveImages()
                                ->conversion('thumb')
                                ->conversionsDisk('public')
                                ->uploadingMessage('Uploading ...'),
                            SpatieMediaLibraryFileUpload::make('picture2')
                                ->required()
                                ->label('Picture 2')
                                ->columnSpan(2)
                                ->collection('about-event-2')
                                ->responsiveImages()
                                ->conversion('thumb')
                                ->conversionsDisk('public')
                                ->uploadingMessage('Uploading ...'),
                            SpatieMediaLibraryFileUpload::make('picture3')
                                ->required()
                                ->label('Picture 3')
                                ->columnSpan(2)
                                ->collection('about-event-3')
                                ->responsiveImages()
                                ->conversion('thumb')
                                ->conversionsDisk('public')
                                ->uploadingMessage('Uploading ...'),
                            SpatieMediaLibraryFileUpload::make('picture4')
                                ->required()
                                ->label('Picture 4')
                                ->columnSpanFull()
                                ->collection('about-event-4')
                                ->responsiveImages()
                                ->conversion('thumb')
                                ->conversionsDisk('public')
                                ->uploadingMessage('Uploading ...'),
                            TextInput::make('tagline')
                                ->required()
                                ->maxLength(255)
                                ->columnSpanFull(),
                            RichEditor::make('description')
                                ->label('About')
                                ->columnSpanFull(),
                            Repeater::make('highlights')
                            ->relationship()
                            ->schema([
                                SpatieMediaLibraryFileUpload::make('icon')
                                    ->columnSpanFull()
                                    ->collection('highlights')
                                    // ->responsiveImages()
                                    // ->conversion('thumb')
                                    ->conversionsDisk('public')
                                    ->uploadingMessage('Uploading ...')
                                    ->helperText('Download icons at svgrepo.com.'),
                                TextInput::make('name')
                                    ->required()
                                    ->maxLength(255),
                                TextInput::make('description')
                                    ->label('Short Description')
                                    ->required()
                                    ->maxLength(255),
                            ])->columnSpanFull(),
                        ])->columns(4), 
                    Wizard\Step::make('Audiences')
                        ->schema([
                            Repeater::make('targetAudiences')
                            ->relationship()
                            ->schema([
                                TextInput::make('name')
                                    ->required()
                                    ->maxLength(255)
                                    ->columnSpanFull(),
                            ])
                            ->columns(4)
                        ]), 
                    Wizard\Step::make('Tickets')
                        ->schema([
                            Repeater::make('tickets')
                            ->relationship()
                            ->schema([
                                TextInput::make('name')
                                    ->required()
                                    ->maxLength(255)
                                    ->columnSpan(1),
                                Select::make('type')
                                    ->options([
                                        'complimentary' => 'Complimentary',
                                        'paid' => 'Paid',
                                    ])
                                    ->native(false)
                                    ->columnSpan(1),
                                Select::make('currency_id')
                                    ->label('Currency')
                                    ->options(Currency::all()->pluck('code', 'id'))
                                    ->searchable(),
                                TextInput::make('price')
                                    ->required()
                                    ->columnSpan(1),
                                Repeater::make('benefits')
                                ->relationship()
                                ->schema([
                                    TextInput::make('benefit')
                                        ->required()
                                        ->maxLength(255)
                                        ->columnSpanFull(),
                                ])->columnSpanFull(),
                                Textarea::make('note')
                                    ->columnSpanFull(),
                            ])
                            ->columns(4)
                        ]), 
                ])->skippable()->columnSpanFull(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')
                    ->searchable(),
                Tables\Columns\TextColumn::make('category.name'),
                ToggleColumn::make('is_active')
                    ->label('Status')
                    ->beforeStateUpdated(function ($record, $state) {
                        if ($record->is_active === $state) {
                            return $state;
                        }

                        return $state;
                    })
                    ->afterStateUpdated(function ($record, $state) {
                        if ($record->is_active === $state) {
                            return;
                        }

                        $record->save();
                    })
                    ->sortable(),
            ])
            ->filters([
                SelectFilter::make('status')
                ->options([
                    '1' => 'Active',
                    '0' => 'Inactive',
                ])
                ->attribute('is_active')
            ])
            ->actions([
                ActionGroup::make([
                    // Tables\Actions\ViewAction::make(),
                    Action::make('Speakers')
                        ->icon('heroicon-m-user-group')
                        ->url(
                            fn (Event $record): string => static::getUrl('speakers.index', [
                                'parent' => $record->id,
                            ])
                    ), 
                    Action::make('Registrants')
                        ->icon('heroicon-m-users')
                        ->url(
                            fn (Event $record): string => static::getUrl('registrants.index', [
                                'parent' => $record->id,
                            ])
                    ),   
                    Action::make('Sponsors')
                        ->icon('heroicon-m-photo')
                        ->url(
                            fn (Event $record): string => static::getUrl('sponsors.index', [
                                'parent' => $record->id,
                            ])
                    ),    
                    Tables\Actions\EditAction::make(),
                    Tables\Actions\DeleteAction::make(),
                ])
                ->button()
                ->label('Actions'),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListEvents::route('/'),
            'create' => Pages\CreateEvent::route('/create'),
            'view' => Pages\ViewEvent::route('/{record}'),
            'edit' => Pages\EditEvent::route('/{record}/edit'),

            'speakers.index' => ListSpeakers::route('/{parent}/speakers'),
            'speakers.create' => CreateSpeaker::route('/{parent}/speakers/create'),
            'speakers.edit' => EditSpeaker::route('/{parent}/speakers/{record}/edit'),

            'sponsors.index' => ListSponsors::route('/{parent}/sponsors'),
            'sponsors.create' => CreateSponsor::route('/{parent}/sponsors/create'),
            'sponsors.edit' => EditSponsor::route('/{parent}/sponsors/{record}/edit'),

            'registrants.index' => ListRegistrants::route('/{parent}/registrants'),
            'registrants.edit' => EditRegistrant::route('/{parent}/registrants/{record}/edit'),
        ];
    }
}

SpeakerResource.php

<?php

namespace App\Filament\Resources;

use Filament\Forms;
use Filament\Tables;
use App\Models\Speaker;
use Filament\Forms\Form;
use Filament\Tables\Table;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Contracts\Support\Htmlable;
use App\Filament\Resources\SpeakerResource\Pages;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use App\Filament\Resources\SpeakerResource\RelationManagers;

class SpeakerResource extends Resource
{
    public static string $parentResource = EventResource::class; 
 
    public static function getRecordTitle(?Model $record): string|null|Htmlable
    {
        return $record->title;
    }
    
    protected static ?string $model = Speaker::class;

    protected static bool $shouldRegisterNavigation = false;

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

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                SpatieMediaLibraryFileUpload::make('photo')
                    ->required()
                    ->columnSpanFull()
                    ->collection('speakers')
                    ->responsiveImages()
                    ->conversion('thumb')
                    ->conversionsDisk('public')
                    ->uploadingMessage('Uploading ...'),
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('occupation')
                    ->required()
                    ->maxLength(255),
                Forms\Components\Toggle::make('is_active')
                    ->required(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->reorderable('sort_order')
            ->columns([
                SpatieMediaLibraryImageColumn::make('photo')
                    ->collection('speakers')
                    ->conversion('thumb')
                    ->width(150)
                    ->height(150)
                    ->label('Photo'),
                Tables\Columns\TextColumn::make('name')
                    ->searchable(),
                Tables\Columns\TextColumn::make('occupation')
                    ->searchable(),
                ToggleColumn::make('is_active')
                    ->label('Status')
                    ->beforeStateUpdated(function ($record, $state) {
                        if ($record->is_active === $state) {
                            return $state;
                        }

                        return $state;
                    })
                    ->afterStateUpdated(function ($record, $state) {
                        if ($record->is_active === $state) {
                            return;
                        }

                        $record->save();
                    })
                    ->sortable(),
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
                Tables\Columns\TextColumn::make('updated_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                SelectFilter::make('status')
                ->options([
                    '1' => 'Active',
                    '0' => 'Inactive',
                ])
                ->attribute('is_active')
            ])
            ->defaultSort('sort_order', 'asc')
            ->actions([
                Tables\Actions\EditAction::make()
                ->url(
                    fn (Pages\ListSpeakers $livewire, Model $record): string => static::$parentResource::getUrl('speakers.edit', [
                        'record' => $record,
                        'parent' => $livewire->parent,
                    ])
                ),
                Tables\Actions\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }
}

**HasParentResource.php

<?php

namespace App\Filament\Traits;

use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;

trait HasParentResource
{
    public Model|int|string|null $parent = null;

    public function bootHasParentResource(): void
    {
        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
    {
        $query = parent::applyFiltersToTableQuery($query);

        return $query->where($this->getParentRelationshipKey(), $this->parent->getKey());
    }

    public function getParentRelationshipKey(): string
    {
        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;
    }
}

How do I make the delete speaker function work?


Solution

  • Solved, by changing Tables\Actions\DeleteAction::make() to

    Action::make('delete')
        ->requiresConfirmation()
        ->color('danger')
        ->icon('heroicon-m-trash')
        ->action(fn (Sponsor $record) => $record->delete())