Why Filament v4 Introduced a Unified Schema API
In Filament v3, form(Form $form) and infolist(Infolist $infolist) lived in completely separate methods with separate component trees. Sharing layout logic between them meant either duplicating field arrays or reaching for abstract helper methods that felt bolted on.
Filament v4 solves this with the Schema API: a single component tree that both the form renderer and the infolist renderer consume. Fields declare how they behave in each context, and the framework resolves the correct representation at render time.
The Core Concept: Schema as a First-Class Object
Instead of returning a configured Form or Infolist, your resource now returns a Schema:
use Filament\Schema\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Infolists\Components\TextEntry;
public static function schema(Schema $schema): Schema
{
return $schema->components([
TextInput::make('name')
->required()
->maxLength(255),
Select::make('status')
->options(Status::class)
->required(),
]);
}
The form() and infolist() methods on the resource can now delegate to schema(), or you can override them individually when the display context genuinely differs.
public static function form(Form $form): Form
{
return $form->schema(static::schema(new Schema($form->getLivewire()))->getComponents());
}
public static function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
TextEntry::make('name'),
TextEntry::make('status')
->badge()
->color(fn (Status $state) => $state->color()),
]);
}
When the infolist needs richer display logic (badges, icons, formatted values), you override it explicitly. When it doesn't, the shared schema is enough.
Field Resolution and State Hydration
Under the hood, Schema components implement HasState. During form hydration, each component calls $this->getState() which reads from the Livewire component's data array. During infolist rendering, the same component tree reads from the bound $record model.
This dual-context resolution is why a TextInput can render as an <input> in a form and as plain text in an infolist without you writing two components.
// A custom field that behaves correctly in both contexts
class MoneyInput extends Field
{
protected function setUp(): void
{
parent::setUp();
$this->formatStateUsing(fn ($state) => $state ? number_format($state / 100, 2) : null);
$this->dehydrateStateUsing(fn ($state) => (int) (floatval(str_replace(',', '', $state)) * 100));
}
}
formatStateUsing runs in both form and infolist contexts. dehydrateStateUsing only fires when the form is submitted — the infolist never calls it.
Practical Migration Pattern for Existing Resources
The safest migration path is incremental:
- Extract shared layout into a static
baseSchema()method returning a plain array. - Keep
form()andinfolist()separate until you've verified parity. - Collapse to
schema()once the infolist no longer needs custom entries.
private static function baseSchema(): array
{
return [
TextInput::make('title')->required(),
TextInput::make('slug')->unique(ignoreRecord: true),
];
}
public static function form(Form $form): Form
{
return $form->schema([
...static::baseSchema(),
FileUpload::make('cover_image'),
]);
}
public static function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
TextEntry::make('title'),
TextEntry::make('slug'),
ImageEntry::make('cover_image'),
]);
}
This pattern keeps diffs reviewable and avoids a big-bang rewrite.
Key Takeaways
- The unified
SchemaAPI eliminates the primary source of duplication between forms and infolists in Filament v4. formatStateUsinganddehydrateStateUsingare context-aware; only dehydration is skipped in infolist rendering.- Custom fields built on
Fieldwork in both contexts without modification if they respect the state lifecycle. - Incremental migration via a shared
baseSchema()array is safer than rewriting resources all at once. - Override
form()orinfolist()explicitly when display requirements genuinely diverge — the unified API is a tool, not a mandate.