The final lesson in this mini-course is about ready-made examples. Need inspiration for customizing the forms? Let's take a look at a few open-source projects.
The first example is from an open-source project ploi/roadmap.
Here, we have a Settings page where different settings are separated into tabs.
class Settings extends SettingsPage{ // ... public function form(Form $form): Form { return $form->schema( [ Tabs::make('main') ->persistTabInQueryString() ->schema( [ Tabs\Tab::make(trans('settings.general-title')) ->schema( [ Section::make('') ->columns() ->schema( [ // Regular form fields in the section ] ), // Other form fields ] ), Tabs\Tab::make(trans('settings.default-boards-title')) ->schema( [ // Regular form fields ] ) ->columnSpan(2) ->visible(fn (Get $get) => $get('create_default_boards')), ] ), Tabs\Tab::make(trans('settings.dashboard-items-title')) ->schema( [ // Regular form fields ] ), Tabs\Tab::make(trans('settings.changelog-title')) ->schema( [ // Regular form fields ] ), Tabs\Tab::make(trans('settings.notifications-title')) ->schema( [ // Regular form fields ] ), Tabs\Tab::make(trans('settings.scripts-title')) ->schema( [ // Regular form fields ] ), Tabs\Tab::make(trans('settings.search-title')) ->schema( [ // Regular form fields ] ), Tabs\Tab::make(trans('settings.profanity-title')) ->schema( [ // Regular form fields ) ] ) ->columns() ->columnSpan(2), ] ); } // ...}
The second example is from an open-source project Hasnayeen/invobook. Here, we have a custom page for the user's profile. Each tab has its own form.
The page class with defined three forms looks like this:
class Profile extends Page implements HasForms{ // ... protected function getForms(): array { return [ 'detailsForm', 'updatePasswordForm', 'rateForm', ]; } public function detailsForm(Form $form): Form { return $form ->schema([ Section::make(['Details']) ->schema([ Fieldset::make('Personal Information') ->columns(2) ->schema([ TextInput::make('name') ->autofocus() ->required(), TextInput::make('email') ->email() ->required() ->columnStart(1), ]), ]), ]) ->statePath('detailsData') ->model($this->user); } public function updatePasswordForm(Form $form): Form { return $form ->schema([ Section::make(['Details']) ->schema([ Fieldset::make('Update Password') ->columns(2) ->schema([ TextInput::make('current_password') ->password(), TextInput::make('new_password') ->password() ->autocomplete('new-password') ->columnStart(1), TextInput::make('password_confirmation') ->password() ->autocomplete('new-password') ->columnStart(1), ]), ]), ]) ->statePath('detailsData') ->model(auth()->user()); } public function rateForm(Form $form): Form { return $form ->schema([ Section::make(['Details']) ->schema([ Fieldset::make('Default Rate') ->columns(2) ->schema([ TextInput::make('default.amount_in_cents') ->label('Amount') ->integer() ->requiredWith('default.currency') ->formatStateUsing(fn ($state) => $state / 100) ->dehydrateStateUsing(fn ($state) => $state * 100), Select::make('default.currency') ->label('Currency') ->requiredWith('default.amount_in_cents') ->options(ISOCurrencyProvider::getInstance()->getAvailableCurrencies()) ->columnStart(1), ]), Fieldset::make('Rate for this team') ->columns(2) ->schema([ TextInput::make('team.amount_in_cents') ->label('Amount') ->integer() ->requiredWith('team.currency') ->formatStateUsing(fn ($state) => $state / 100) ->dehydrateStateUsing(fn ($state) => $state * 100), Select::make('team.currency') ->label('Currency') ->requiredWith('team.amount_in_cents') ->options(ISOCurrencyProvider::getInstance()->getAvailableCurrencies()) ->columnStart(1), ]), ]), ]) ->statePath('rateData') ->model(auth()->user()); } // ...}
Then, because this is a custom page, tabs are added manually using the Tabs Blade component. When a tab is active, the corresponding form is shown.
<x-filament::page> <div x-data="{ activeTab: 'detailsForm' }" class="space-y-6"> <x-filament::tabs label="Content tabs" contained> <x-filament::tabs.item icon="lucide-file-text" alpine-active="activeTab === 'detailsForm'" x-on:click="activeTab = 'detailsForm'" > {{ __('Details') }} </x-filament::tabs.item> <x-filament::tabs.item icon="lucide-lock" alpine-active="activeTab === 'updatePasswordForm'" x-on:click="activeTab = 'updatePasswordForm'" > {{ __('Update Password') }} </x-filament::tabs.item> <x-filament::tabs.item icon="lucide-banknote" alpine-active="activeTab === 'rateForm'" x-on:click="activeTab = 'rateForm'" > {{ __('Rate') }} </x-filament::tabs.item> </x-filament::tabs> <form x-ref="detailsForm" :class="activeTab === 'detailsForm' || 'hidden'" class="space-y-6" wire:submit="saveDetails"> {{ $this->detailsForm }} <x-filament::button type="submit"> {{ __('Save') }} </x-filament::button> </form> <form x-ref="updatePasswordForm" :class="activeTab === 'updatePasswordForm' || 'hidden'" class="space-y-6" wire:submit="savePassword"> {{ $this->updatePasswordForm }} <x-filament::button type="submit"> {{ __('Save') }} </x-filament::button> </form> <form x-ref="rateForm" :class="activeTab === 'rateForm' || 'hidden'" class="space-y-6" wire:submit="saveRates"> {{ $this->rateForm }} <x-filament::button type="submit"> {{ __('Save') }} </x-filament::button> </form> </div> <x-filament-actions::modals /></x-filament::page>
The third example is from our own FilamentExamples. In this example, we have two groups. The first group is on the left and wider. Everything in the first group is inside a section. The second group is on the right and contains two sections.
class PostResource extends Resource{ // ... public static function form(Form $form): Form { return $form ->schema([ Forms\Components\Group::make() ->schema([ Forms\Components\Section::make() ->schema([ Forms\Components\TextInput::make('title') ->required() ->live(onBlur: true) ->afterStateUpdated(fn(Set $set, ?string $state) => $set('slug', Str::slug($state))), Forms\Components\TextInput::make('slug') ->required(), Forms\Components\RichEditor::make('content') ->live(onBlur: true) ->required(), Forms\Components\Textarea::make('excerpt') ->required(), Forms\Components\Actions::make([ Forms\Components\Actions\Action::make('Generate excerpt') ->action(function (Forms\Get $get, Set $set) { $set('excerpt', str($get('content'))->stripTags()->words(45, end: '')); }) ->size(ActionSize::ExtraSmall) ]), Forms\Components\Select::make('tags') ->multiple() ->relationship('tags', 'name'), Forms\Components\Select::make('category_id') ->relationship('category', 'name') ->required(), ])->columns(1), ])->columnSpan(2), Forms\Components\Group::make() ->schema([ Forms\Components\Section::make('Featured Image') ->schema([ Forms\Components\SpatieMediaLibraryFileUpload::make('featured_image') ->live() ->image() ->hiddenLabel() ->collection('featured_image') ->rules(Rule::dimensions()->maxWidth(600)->maxHeight(800)) ->afterStateUpdated(function (Forms\Contracts\HasForms $livewire, Forms\Components\SpatieMediaLibraryFileUpload $component) { $livewire->validateOnly($component->getStatePath()); }), ]), Forms\Components\Section::make() ->schema([ Forms\Components\Select::make('author_id') ->label('Author') ->relationship('author', 'name') ->required(), Forms\Components\DateTimePicker::make('published_at') ->default(now()), ]), ])->columnSpan(1), ])->columns(3); } // ...}
The last example is from an open-source project frikishaan/tiny-crm. Similar to the previous example, here we have two sections where one is wider.
class DealResource extends Resource{ // ... public static function form(Form $form): Form { return $form ->schema([ Section::make() ->schema([ TextInput::make('title') ->required() ->disabled(fn(?Deal $record) => in_array($record?->status, [2, 3])), Select::make('customer_id') ->label('Customer') ->options(Account::all()->pluck('name', 'id')) ->searchable() ->disabled(fn(?Deal $record) => in_array($record?->status, [2, 3])) ->required(), Select::make('lead_id') ->label('Originating lead') ->options(Lead::all()->pluck('title', 'id')) ->searchable() ->disabled(fn(?Deal $record) => in_array($record?->status, [2, 3])), RichEditor::make('description') ->disableToolbarButtons([ 'attachFiles', 'codeBlock' ]) ]) ->columnSpan(2), Section::make() ->schema([ Select::make('status') ->options([ 1 => 'Open', 2 => 'Won', 3 => 'Lost' ]) ->visible(fn(?Deal $record) => $record != null) ->disabled(), TextInput::make('estimated_revenue') ->label('Estimated revenue') ->mask(RawJs::make('$money($input)')) ->stripCharacters(',') ->numeric() ->disabled(fn(?Deal $record) => in_array($record?->status, [2, 3])), TextInput::make('actual_revenue') ->label('Actual revenue') ->mask(RawJs::make('$money($input)')) ->stripCharacters(',') ->numeric() ->disabled(fn(?Deal $record) => in_array($record?->status, [2, 3])) ]) ->columnSpan(1) ]) ->columns(3); } // ...}