Why the Old Approach Felt Redundant
In Filament v3, a typical resource forced you to define form() and infolist() separately. If your OrderResource displayed twelve fields, you wrote those fields twice — once as TextInput components, once as TextEntry components. The logic was identical; only the component class differed. Senior engineers reached for shared methods or traits to paper over the duplication, but it was always a workaround.
Filament v4 addresses this at the framework level with the unified Schema API.
The Unified Schema API in Practice
The core idea: a Schema is a context-aware tree of components. The same schema definition renders as an editable form inside CreateRecord or EditRecord, and as a read-only infolist inside ViewRecord. Components resolve their own rendering based on the active context.
use Filament\Schema\Schema;
use Filament\Schema\Components\TextInput;
use Filament\Schema\Components\Select;
use Filament\Schema\Components\Section;
public function schema(Schema $schema): Schema
{
return $schema->components([
Section::make('Customer')
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Select::make('status')
->options(OrderStatus::class)
->required(),
]),
]);
}
You define schema() once on the resource. Filament resolves TextInput to an editable input on the form page and to a read-only text entry on the view page — no duplication, no trait gymnastics.
Opting Into Context-Specific Behaviour
Sometimes a field genuinely needs different behaviour per context. The API provides ->visibleOn() and ->hiddenOn() helpers, plus the lower-level ->when() callback that receives the active context string ('form', 'infolist'):
TextInput::make('internal_notes')
->hiddenOn('infolist'),
Select::make('assigned_to')
->options(fn () => User::pluck('name', 'id'))
->when(
fn (string $context) => $context === 'form',
fn (Select $field) => $field->searchable()->preload()
),
This keeps the single-schema principle intact while giving you an escape hatch.
Reusable Schema Fragments
Because a schema is just a PHP value, you can extract fragments into dedicated classes and compose them:
class AddressSchema
{
public static function make(): array
{
return [
TextInput::make('street')->required(),
TextInput::make('city')->required(),
TextInput::make('postcode')->required(),
];
}
}
// Inside your resource:
Section::make('Shipping Address')
->schema(AddressSchema::make()),
This is the pattern that replaces the old getFormSchema() / getInfolistSchema() split. One fragment, used everywhere.
Table Columns Are Still Separate
It is worth being explicit: the unified Schema API covers forms and infolists only. Table column definitions remain in table() and have not been merged. This is intentional — table columns carry sorting, searching, and bulk-action concerns that do not map cleanly onto a field/entry duality.
Testing the New Schema
Pest assertions against Filament v4 schemas use the same livewire() helper, but the component class changes:
use function Pest\Livewire\livewire;
it('saves an order', function () {
livewire(\App\Filament\Resources\OrderResource\Pages\CreateOrder::class)
->fillForm([
'name' => 'Acme Corp',
'status' => 'pending',
])
->call('create')
->assertHasNoFormErrors();
});
The assertion surface is unchanged; only the underlying schema resolution differs.
Key Takeaways
- One
schema()method replaces separateform()andinfolist()definitions on a resource. - Components are context-aware — they render as inputs or entries depending on the active page.
- Use
->when()or->hiddenOn()for the rare cases where context-specific behaviour is genuinely needed. - Extract reusable fragments into plain PHP classes; they compose cleanly into any schema.
- Table columns remain separate — the unification is scoped to forms and infolists.
- Existing Pest assertions continue to work; no test-layer rewrite is required.