Back to Course |
[Mini-course] Filament: Visual Customizations

Forms Layouts: 4 Real-Life Examples

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.


1. Plot/Roadmap: Tabs & Sections

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

2. Hasnayeen/invobook: Tabs, Section, and Fieldset

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>

3. Filament Examples CMS: Group & Section

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);
}
 
// ...
}

4. Frikishaan/tiny-crm: Section

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);
}
 
// ...
}