Filament v4 Schema-Based Forms: Unified Schema API in Practice
Filament v4 introduced one of its most architecturally significant changes: a unified Schema API that replaces the previously separate form() and infolist() definition styles with a single, composable schema() surface. If you have a non-trivial Filament v3 codebase, understanding this shift before you migrate will save you hours of confusion.
What Changed and Why It Matters
In Filament v3, forms and infolists lived in parallel universes. You defined form(Form $form) with Components\TextInput, and separately infolist(Infolist $infolist) with Entries\TextEntry. The duplication was real — the same field often had a twin entry just to display it read-only.
Filament v4 collapses this into a single schema(Schema $schema) method. Components are now context-aware: a TextInput can render as an editable field inside a form context and as a read-only entry inside an infolist context, driven by the same definition.
use Filament\Schema\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
public function schema(Schema $schema): Schema
{
return $schema->components([
TextInput::make('name')
->required()
->maxLength(255),
Select::make('status')
->options(Status::class)
->native(false),
]);
}
The same array powers both the create/edit form and the view infolist. No more maintaining two lists.
Composing Reusable Schema Fragments
The real power emerges when you extract domain-specific schema fragments into dedicated classes. Think of these as DTOs for your UI structure.
namespace App\Filament\Schemas;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
final class AddressSchema
{
public static function make(string $prefix = ''): array
{
$field = fn(string $name) => $prefix ? "{$prefix}.{$name}" : $name;
return [
Fieldset::make('Address')
->schema([
TextInput::make($field('line1'))->required(),
TextInput::make($field('city'))->required(),
TextInput::make($field('postcode'))->required(),
]),
];
}
}
Now any resource that needs an address block calls AddressSchema::make('billing') — one definition, zero drift between form and infolist.
Context-Specific Overrides
Sometimes a field genuinely needs different behaviour in edit vs. view mode. The Schema API provides ->hiddenOn() and ->visibleOn() helpers, plus the ->formatStateUsing() callback that only fires during infolist rendering.
TextInput::make('api_key')
->password()
->revealable()
->hiddenOn('view'), // hide raw key in infolist
TextEntry::make('api_key')
->formatStateUsing(fn($state) => '••••' . substr($state, -4))
->visibleOn('view'),
This pattern keeps sensitive fields safe without splitting your schema definition.
Custom Schema Components
Building a custom component is now a single class that extends Component and declares both its form and infolist views.
namespace App\Filament\Components;
use Filament\Forms\Components\Component;
class MoneyInput extends Component
{
protected string $view = 'filament.components.money-input';
public static function make(string $name): static
{
return app(static::class, ['name' => $name]);
}
}
The Blade view receives $getState(), $isDisabled(), and the full component API — no separate Entry class required.
Performance Note
Because the Schema API resolves components lazily and shares the same component tree for both read and write contexts, Filament v4 avoids instantiating duplicate component graphs per page load. On resource pages with 30+ fields this is a measurable reduction in object allocations, though the gains are most visible under Octane where those allocations compound across requests.
Key Takeaways
- One
schema()method replaces bothform()andinfolist()in Filament v4. - Extract reusable field groups into static schema fragment classes to eliminate duplication.
- Use
->visibleOn()/->hiddenOn()for context-specific rendering without splitting definitions. - Custom components now require one class and one view, not a form component plus an infolist entry.
- The unified tree means fewer object allocations, which matters under long-running runtimes like Octane.