Filament resources start clean and accrete. A product form begins with six fields, then SEO metadata arrives, then shipping dimensions, then a tax group, then three toggles nobody can explain. Now it is forty inputs in one vertical column and the save button is a full page-scroll away. Everyone agrees it should be grouped. Far fewer people agree on which component to group it with, because the Filament form layout components — Section, Fieldset, Tabs, Grid and Flex — all look interchangeable until you have committed to one.
Understand why Filament form layout components are free to add#
Before anything else: layout components hold no state. Section, Fieldset, Tabs, Grid, Group and Flex are presentational wrappers. They do not appear in $data, they do not affect validation rules, and they do not change what gets written to the model. Restructuring the layout of an existing form is a zero-risk change — no migration, no model edit, no test rewrite.
That is the unlock. You can wrap, nest and reorder freely, and if it reads worse you throw it away.
One naming change to get out of the way first. In v5 everything layout-related lives under Filament\Schemas\Components, not Filament\Forms\Components, and the old Split component is gone — it is now Flex. If you are coming from a v3 or v4 codebase, that rename is one of many in the step-by-step migration path from v4 to v5.
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Flex; // was Split
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
Reach for Section first#
Section is the default answer and it is right most of the time. It gives you a heading, an optional description, an icon, and a card container — and crucially, ->collapsed(), which is the single highest-value change you can make to a long form. The three fields people actually edit stay visible; the thirty they do not fold out of the way but stay one click from reach.
Section::make('Product details')
->description('The information customers see on the product page.')
->icon('heroicon-o-cube')
->columns(2) // a section renders a single column until you say otherwise
->schema([
TextInput::make('name')->required(),
TextInput::make('sku')->required(),
Textarea::make('description')->columnSpanFull(),
]),
Section::make('Shipping')
->description('Dimensions and weight, used to calculate delivery cost.')
->collapsed() // starts closed
->collapsible()
->persistCollapsed() // remembers the user's choice
->columns(3)
->schema([
TextInput::make('length_mm')->numeric(),
TextInput::make('width_mm')->numeric(),
TextInput::make('height_mm')->numeric(),
]),
persistCollapsed() stores the open/closed state in local storage, keyed off the section heading. If two sections share a heading — or a section has no heading at all — give one an explicit ->id('shipping') or they will collapse together.
Two more worth knowing. ->compact() tightens the padding for dense forms. ->secondary() renders a lower-contrast variant, useful for a section that is genuinely secondary rather than merely further down the page. There is no contained() on Section in v5 — that method belongs to Tabs and Fieldset.
Use Fieldset for small related groups#
Fieldset is the lighter option: a label, a border, and a two-column grid by default. No collapse, no description, no card. It is for a small cluster of related inputs inside a section, where a nested Section would add a heading level you do not want.
Section::make('Pricing')
->schema([
Fieldset::make('Retail')
->schema([
TextInput::make('price')->numeric()->prefix('£'),
TextInput::make('compare_at_price')->numeric()->prefix('£'),
]),
Fieldset::make('Tax')
->columns(['default' => 1, 'md' => 2, 'xl' => 3])
->schema([
Select::make('tax_group_id')->relationship('taxGroup', 'name'),
Toggle::make('tax_inclusive'),
TextInput::make('tax_code'),
]),
]),
The rule I use: if the group needs a sentence of explanation or deserves to be collapsed, it is a Section. If it just needs a line drawn around it, it is a Fieldset.
Add tabs only when the groups are independent#
Tabs reduces what is on screen more aggressively than collapsing, and that is exactly the problem. Use it when the groups are genuinely independent — content versus SEO versus inventory — not merely when the form is long.
Tabs::make('Product')
->id('product-form-tabs') // required by persistTab()
->persistTab()
->tabs([
Tab::make('Content')
->icon('heroicon-o-document-text')
->schema([/* ... */]),
Tab::make('Variants')
->badge(fn (?Product $record) => $record?->variants()->count())
->schema([/* ... */]),
Tab::make('SEO')
->schema([/* ... */]),
]),
->persistTab() needs that id(); without it Filament has no way to tell one set of tabs from another. ->persistTabInQueryString() puts the active tab in the URL instead, which makes tabs linkable. ->activeTab(2) opens the second tab by default and is 1-indexed by position, not by key.
Now the trap. Put a required field on the third tab, leave it empty, and submit from the first tab. Validation fails. The error message is rendered — into a panel the user cannot see. Filament does not switch to the offending tab and does not mark it. I checked the v5.8 source: neither Tabs\Tab nor the tabs Alpine component has any concept of a validation error. From the user's side this is a save button that does nothing.
If you are keeping tabs, build the indicator yourself:
Tab::make('Shipping')
->badge(fn ($livewire) => collect($livewire->getErrorBag()->keys())
->filter(fn (string $key) => str_starts_with($key, 'data.shipping_'))
->count() ?: null) // null renders no badge
->badgeColor('danger')
->schema([/* ... */]),
It relies on a naming convention across the fields in that tab, which is a smell, but it is better than a silent failure. My default is still Section with ->collapsed() — a collapsed section is in the document flow, so the browser can scroll to the error inside it.
And tabs are not a wizard. A wizard is for a sequential, gated creation flow where step two depends on step one; I covered that separately in building a multi-step create wizard. Tabs are for one form the user can fill in any order.
Build the sidebar layout with Flex#
Flex is the component most people never find, and it produces the layout every admin form you actually like already uses: primary fields in a wide left column, metadata in a narrow right rail. It uses flexbox rather than Filament's grid system, so it sits outside the column-span rules entirely.
Flex::make([
Section::make([
TextInput::make('title')->required(),
RichEditor::make('body'),
]),
Section::make([
Toggle::make('is_published'),
Toggle::make('is_featured'),
Select::make('author_id')->relationship('author', 'name'),
DateTimePicker::make('published_at'),
])->grow(false), // the rail keeps its natural width
])->from('md'), // stacks vertically below the md breakpoint
Two details do all the work. ->grow(false) on the second section stops it expanding, so the left side takes the slack. ->from('md') picks the Tailwind breakpoint at which the horizontal split kicks in — below it, the rail drops underneath, which is what you want on a phone.
Note that Flex::make() takes its children as a direct array argument. It has no ->schema() call, unlike every other layout component, and that catches people.
Fix column spans that nesting has reset#
->columns(3) on a parent and ->columnSpan(2) on a child is the grid system in one line. The rule people trip over is that a span is relative to its immediate parent grid — not to the form, and not to the section two levels up.
Here is the version that looks broken:
Section::make('Dimensions')
->columns(3)
->schema([
Grid::make() // defaults to a single column
->schema([
TextInput::make('length_mm')->columnSpan(2), // does nothing visible
TextInput::make('width_mm'),
]),
]),
length_mm sits inside a one-column Grid, so spanning two of one column is meaningless. The section's three columns are irrelevant to it. Either give the nested grid the columns it needs, or drop the wrapper — Group is the component to reach for when you need to bundle children without introducing a grid or any visual styling.
Section::make('Dimensions')
->columns(3)
->schema([
TextInput::make('length_mm')->columnSpan(2),
TextInput::make('width_mm'),
Textarea::make('packaging_notes')->columnSpanFull(),
]),
The forms of columnSpan() worth memorising: an integer applies at the lg breakpoint and up; an array like ['md' => 2, 'xl' => 4] sets it per breakpoint; 'full' fills the parent from lg up; and columnSpanFull() fills it on every device. That last distinction is the one that bites — columnSpan('full') and columnSpanFull() are not synonyms.
There is also a container-query mode, where @md and @xl respond to the container's width rather than the viewport's. It is genuinely useful for a section that renders both full-width and inside a narrow Flex rail, but it needs an explicit opt-in:
Grid::make()
->gridContainer() // without this, the @ breakpoints do nothing
->columns([
'@md' => 3,
'@xl' => 4,
])
->schema([/* ... */]),
Assemble the whole Filament form layout#
Put together, the forty-field product form becomes a shape you can read at a glance: a Flex splitting primary content from a metadata rail, sections inside each side, and a fieldset for the one cluster that only needed a border.
public static function form(Schema $schema): Schema
{
return $schema
->components([
Flex::make([
Group::make([
Section::make('Product details')
->columns(2)
->schema([/* 8 fields */]),
Section::make('Pricing')
->columns(2)
->schema([
Fieldset::make('Tax')->schema([/* 3 fields */]),
]),
Section::make('Shipping')
->collapsed()
->collapsible()
->persistCollapsed()
->id('product-shipping')
->columns(3)
->schema([/* 6 fields */]),
]),
Section::make('Status')
->schema([
Toggle::make('is_active'),
Select::make('status'),
DateTimePicker::make('published_at'),
])
->grow(false),
])->from('lg'),
]);
}
Group is doing quiet work there: Flex wants exactly two children to split, so the three stacked sections need a wrapper, and Group adds no styling of its own. If a section in that tree needs to explain itself — a warning that changing the tax group recalculates existing orders, say — that is what the Callout component is for, rather than another paragraph of description text.
Extract shared sections into a schema class#
Once the create form, the edit form and a relation manager all want the same pricing block, stop copying it. The v5 resource scaffold already points the way: the generated resource delegates to a class in a Schemas directory.
// app/Filament/Resources/Products/Schemas/ProductForm.php
namespace App\Filament\Resources\Products\Schemas;
use Filament\Schemas\Schema;
class ProductForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
self::pricingSection(),
// ...
]);
}
public static function pricingSection(): Section
{
return Section::make('Pricing')
->columns(2)
->schema([/* ... */]);
}
}
// the resource
public static function form(Schema $schema): Schema
{
return ProductForm::configure($schema);
}
Note the terminology: the top-level Schema object takes ->components([...]), while layout components take ->schema([...]). If you want a house-wide default instead — every section two columns, say — configureUsing() in a service provider boot() method does it globally:
Section::configureUsing(function (Section $section): void {
$section->columns(2);
});
Sidestep the gotchas#
A collapsed section hiding a required empty field is the same bug as the tab. The user cannot see what is blocking the save. A collapsed section is at least still in the document flow so the browser can scroll into it, but if a section holds required fields, do not start it collapsed.
->columns() on a section overrides the form's column count, not adds to it. People set columns(2) once at the top of the schema and are then surprised when a section renders one column. The section is its own grid.
deferBadge() needs a key(). If you defer a tab badge that runs a count query, Filament will tell you at render time that the Tabs component must have a key() set. Set it on the Tabs, not the Tab.
Fields inside an inactive tab are still in the DOM. When tabs are not bound to a Livewire property, every panel renders and Alpine toggles a class. That is why validation errors exist but are invisible — and also why a heavy tab still costs you payload even when nobody opens it.
->live() fields behave differently once grouped. Reactivity is unaffected by layout, but a reactive field that used to sit next to its dependent is now two sections away, and the update looks like nothing happened. Keep dependent select fields in the same section as the field that drives them.
Wrapping Up#
Start with one change: wrap the form in three Sections and collapse the two nobody edits daily. It takes ten minutes, touches no state, and is most of the win. Reach for Flex when you want a metadata rail, and treat Tabs as the exception rather than the default until you have built the error badge.
Because layout components carry no state, your existing tests should pass untouched after the refactor — which makes this a good moment to check that you actually have them. Testing Filament resources with Pest covers the assertions worth having before you start moving fields around. And if the panel itself has grown as much as the form has, grouping resources into clusters is the same exercise one level up.
FAQ#
How do I group fields into sections in a Filament form?
Wrap the fields in a Filament\Schemas\Components\Section and pass them to its schema() method. A section takes a heading, an optional description and icon, and can be made collapsible with collapsible() or start closed with collapsed(). Because sections hold no state, adding them to an existing form changes nothing about validation or what is saved.
What is the difference between Section, Fieldset and Tabs in Filament?
A Section is a card with a heading and description that can collapse — the general-purpose grouping component. A Fieldset is lighter: a label, a border and a two-column grid by default, meant for a small cluster inside a section. Tabs hides all but one group at a time, so it reduces visible content the most but also hides validation errors, which is why it should be reserved for genuinely independent groups.
Why are my Filament validation errors invisible when using tabs?
Because the failing field is inside a tab panel the user is not looking at. Filament renders every tab's content into the DOM and toggles visibility in the browser, so the error message exists but is hidden, and nothing switches the user to the tab that failed. As of Filament v5.8 there is no built-in tab error indicator, so you need to add a badge() closure that inspects the Livewire error bag yourself.
How do I make a two-column layout with a sidebar in a Filament form?
Use the Flex component, which replaced Split in Filament v5. Pass it two children — typically a Group of sections for the main content and a single Section for the sidebar — and call grow(false) on the sidebar so it keeps its natural width. Add ->from('md') or ->from('lg') to control the breakpoint below which the two columns stack vertically.
How does columnSpan work in Filament forms?
columnSpan() sets how many columns of the immediate parent grid a component fills, so a span inside a nested Grid is measured against that grid, not the section around it. An integer applies from the lg breakpoint upwards, an array like ['md' => 2, 'xl' => 4] sets it per breakpoint, and columnSpanFull() fills the parent on every device — unlike columnSpan('full'), which only applies from lg up.
Should I use Tabs or a Wizard in Filament?
Use Tabs when the user can complete the groups in any order, such as an edit form split into content, SEO and inventory. Use Wizard when the steps are sequential and gated, where each step validates before the next unlocks, which is almost always a create flow rather than an edit one. A long edit form is a sections problem, not a wizard problem.