Courses

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

I finished the course but my progress is 0

avatar

Hi, sorry about that - we were doing some optimizations and there was a tiny issue. Should be fixed now!

avatar
You can use Markdown
avatar

I was wondering if you have done or could do an example where the Top Navigation is placed in a header instead of the topbar. I am trying to make a layout where I have:

Topbar - site branding and user identification or join/login buttons

Header - main page navigation

Main - content and secondary navigation

Filament will only put main navigation in a sidebar or the topbar. I tried creating a custom layout but don't understand their code well enough to pull it off.

avatar

We haven't done such but the logic would be use render hooks and add filament components to them. Which hook to use check docs. For which component to render only diving into source code you would find. Just today on X someone shared some modifications, might give you some directions.

avatar
You can use Markdown
avatar
You can use Markdown