You need a Filament form field that doesn't ship with the framework. A brand colour picker, a Google Places autocomplete, a dynamic SKU builder. The built-ins won't bend that far. Reach for view() components and you lose the form lifecycle. Hand-roll Alpine state syncing and you're rebuilding what Filament already gives you for free.
Filament v4 added a first-class generator for exactly this case. In about 45 minutes you can scaffold a real form field, wire Alpine state, validate it, make it reactive, and test it with Pest. This walkthrough builds a colour picker bound to a brand palette — the same pattern works for anything else.
Scaffold the field with php artisan make:filament-form-field#
Run the v4 generator from the project root. Note the command is make:filament-form-field — the v3 short alias is gone in v4 and the docs were quietly updated, so this catches a lot of people on upgrade.
php artisan make:filament-form-field ColourPicker
That writes two files: app/Forms/Components/ColourPicker.php for the field class, and resources/views/filament/forms/components/colour-picker.blade.php for the view. Both paths are conventional — the view path is auto-derived from the class's $view property, so don't move them unless you also update the property.
The generated class is intentionally minimal:
<?php
namespace App\Forms\Components;
use Filament\Forms\Components\Field;
class ColourPicker extends Field
{
protected string $view = 'filament.forms.components.colour-picker';
}
The generator does not register the field anywhere. Filament discovers it the moment you call ColourPicker::make('brand_colour') in a schema — no service provider, no panel hook, no asset registration unless you need third-party JavaScript. If you're new to Filament v4's panel-and-schema architecture in general, the Livewire 3 to Livewire 4 migration guide covers the underlying Livewire layer that v4 builds on top of.
Add configuration methods to the field class#
The whole point of a custom field is to make it configurable. In our case, the colour picker should accept a palette of brand colours and an optional allowCustom toggle. The Filament pattern is: a public setter that stores a protected property, plus a public getter that's called from the Blade view as a variable function.
<?php
namespace App\Forms\Components;
use Closure;
use Filament\Forms\Components\Field;
class ColourPicker extends Field
{
protected string $view = 'filament.forms.components.colour-picker';
/** @var array<int, string> | Closure | null */
protected array | Closure | null $palette = null;
protected bool | Closure $allowCustom = false;
/**
* @param array<int, string> | Closure $palette
*/
public function palette(array | Closure $palette): static
{
$this->palette = $palette;
return $this;
}
public function allowCustom(bool | Closure $condition = true): static
{
$this->allowCustom = $condition;
return $this;
}
/** @return array<int, string> */
public function getPalette(): array
{
return $this->evaluate($this->palette) ?? [];
}
public function isCustomAllowed(): bool
{
return (bool) $this->evaluate($this->allowCustom);
}
}
Two things to notice. First, every setter accepts Closure as well as a raw value — this is what enables Filament's utility injection, so callers can pass a function that receives $get, $record, or anything else and Filament resolves it at render time. Second, the getters route through $this->evaluate(...), which is the call that actually runs the closure with the injected utilities. Skip evaluate() and your closures will be returned as objects rather than resolved values — a very confusing rendering bug.
Filament form fields are not Livewire components. Public properties and methods you define here are not magically available as $this->palette in the Blade view. They're surfaced only via the variable-function pattern below.
Build the Blade view with x-data and wire:entangle#
Open the generated resources/views/filament/forms/components/colour-picker.blade.php. Drop in the wrapper that gives you label, helper text, validation messages, and the standard Filament chrome for free — and an Alpine root that entangles state with the field's state path.
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }},
allowCustom: @js($isCustomAllowed()),
}"
class="grid grid-cols-6 gap-2"
>
@foreach ($getPalette() as $colour)
<button
type="button"
x-on:click="state = @js($colour)"
:class="state === @js($colour) ? 'ring-2 ring-primary-500' : 'ring-1 ring-gray-200'"
class="h-9 w-9 rounded-md"
style="background-color: {{ $colour }}"
aria-label="{{ $colour }}"
></button>
@endforeach
<template x-if="allowCustom">
<input
type="color"
x-model="state"
class="col-span-6 mt-2 h-9 w-full rounded-md border border-gray-200"
/>
</template>
</div>
</x-dynamic-component>
The $getFieldWrapperView() call resolves to Filament's standard field wrapper — that's where the label, helper text, and error messages live. Skip the wrapper and you reimplement all of it.
The $wire.$entangle('{{ $getStatePath() }}') binding ties the Alpine state property to the Livewire public property that holds this field's value. Any time the user clicks a swatch, Alpine updates state, the entanglement pushes the value to Livewire on the next request, and validation runs against the updated value. The $applyStateBindingModifiers(...) wrapper means live(), live(debounce: 500), and live(onBlur: true) all work without any extra code in the view.
Wire the field into a Resource form schema#
The field is now a normal Filament component. Drop it into any Resource, Action, or schema the same way you'd use a TextInput or Select.
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ProductResource\Pages;
use App\Forms\Components\ColourPicker;
use App\Models\Product;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
class ProductResource extends Resource
{
protected static ?string $model = Product::class;
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')->required(),
ColourPicker::make('brand_colour')
->label('Brand colour')
->palette([
'#0F172A', '#1E293B', '#0EA5E9',
'#22C55E', '#F59E0B', '#EF4444',
])
->allowCustom()
->required(),
]);
}
// ...
}
The same ColourPicker works inside an Action modal, an Infolist edit form, or a stand-alone Livewire component — no extra registration. If you're using v4's schemas to mix tables, tabs, and forms on the same page, the field plugs into those too.
Add validation rules and reactive callbacks#
Validation works exactly like any built-in field — chain ->rules(...) for Laravel validation rules, or pass a closure for ad-hoc logic that needs to reach into other fields. Reactivity is a ->live() plus a callback.
ColourPicker::make('brand_colour')
->palette(fn (Product $record): array => $record?->team?->palette ?? ['#0F172A', '#0EA5E9'])
->rules([
'regex:/^#[0-9A-Fa-f]{6}$/',
])
->live()
->afterStateUpdated(function (?string $state, callable $set): void {
// Auto-fill a contrasting label colour when the brand colour changes.
$set('label_colour', $state === '#FFFFFF' ? '#0F172A' : '#FFFFFF');
})
->required(),
A few production-relevant details. ->live() flips the entangle modifier to .live, so every swatch click round-trips to the server. That's fine for a small palette, but if your field updates frequently (a slider, a free-text colour entry), prefer ->live(debounce: 400) or ->live(onBlur: true). The afterStateUpdated callback receives the new state plus the schema's $set, $get, and $record callable utilities — the same utility injection covered earlier. For deeper Laravel-side patterns around attribute-driven configuration in callbacks like these, the rundown in Laravel 13 PHP attributes for cleaner models and jobs is worth a skim.
Test the custom field with Pest#
A custom field is a public API. Test it like one. Two test categories matter: validation behaviour, and end-to-end behaviour inside a real Resource.
<?php
use App\Filament\Resources\ProductResource\Pages\CreateProduct;
use App\Models\Product;
use Filament\Actions\Testing\Fixtures\TestAction;
use function Pest\Livewire\livewire;
it('saves the chosen brand colour through the Resource form', function (): void {
livewire(CreateProduct::class)
->fillForm([
'name' => 'Pulse Mug',
'brand_colour' => '#0EA5E9',
])
->call('create')
->assertHasNoFormErrors();
expect(Product::query()->where('name', 'Pulse Mug')->first())
->brand_colour->toBe('#0EA5E9');
});
it('rejects a brand colour that is not a valid hex value', function (): void {
livewire(CreateProduct::class)
->fillForm([
'name' => 'Pulse Mug',
'brand_colour' => 'not-a-colour',
])
->call('create')
->assertHasFormErrors(['brand_colour']);
});
fillForm writes the value into Livewire public state exactly as if the user clicked a swatch in the browser. The form-level validation rule on the field then catches the bad input. If you're new to Pest's broader patterns in a Filament codebase — architecture tests, dataset-driven validation, and grouping — Pest architecture testing for Laravel apps is the right next read. For full end-to-end coverage that actually clicks colour swatches in a real browser, see Pest 4 browser testing with Playwright.
Gotchas and Edge Cases#
The two paths to a custom UI in v4 — view() components and full custom field classes — look interchangeable until they aren't. A view() component (Filament's schema-level custom component, not a form field) is the right call when you need to render arbitrary HTML at a position in a schema and you don't need form state binding, validation, or hydration. The moment you need any of those, the answer is a custom field class — every other approach reinvents the wheel.
The make:filament-form-field generator does not publish the field stub. If you want to customise the boilerplate it produces, you'll need to publish Filament's stub set with php artisan vendor:publish --tag=filament-stubs and edit forms/Field.stub. There's no --inline shortcut yet.
Public methods on the field class are accessible in the Blade view as variable functions ($getZoom()), not as object methods. This trips up developers coming from other frameworks where the view binds to a component instance. If you try $field->getZoom() it works because $field is exposed for exactly this case, but the variable-function pattern is the idiomatic one and matches every built-in field.
Custom fields run fine inside Action modals — you don't need a special wrapper. The one wrinkle: if your field renders Alpine state that's expensive to compute, putting the action behind a lazy-loaded Livewire island avoids paying that cost until the modal actually opens.
#[ExposedLivewireMethod] is new in v4. It lets your field class expose specific methods to JavaScript so the view can call them via $wire.callSchemaComponentMethod($getKey(), 'methodName'). Without the attribute, the call fails — by design, since silently exposing every public method would be a security hole.
Wrapping Up#
Build the field class once, test it once, and reuse it across every panel, Action, and schema in your app. The 45-minute path is the whole investment — after that, custom fields feel like first-class citizens because they are. Start with the simplest possible field for whatever shape your data takes, then layer in configuration methods, validation, and reactivity as the real product pulls them out of you.
If you're shipping more Livewire-heavy UI alongside this, Livewire 4 islands for lazy-loading expensive components is the natural next step. For the broader PHP-attribute patterns that show up across Filament v4 (including #[ExposedLivewireMethod] and #[Renderless]), PHP custom attributes beyond Laravel's built-ins goes deeper than this article needed to.
FAQ#
How do I create a custom form field in Filament v4?
Run php artisan make:filament-form-field {Name} from the project root. Filament writes a class at app/Forms/Components/{Name}.php extending Filament\Forms\Components\Field, plus a matching Blade view at resources/views/filament/forms/components/{kebab-name}.blade.php. You don't register the field anywhere — calling {Name}::make('column') inside any schema is enough for Filament to pick it up.
What is the difference between a Filament custom component and a custom form field?
A custom schema component is for rendering arbitrary HTML at a position in a schema with no form state, validation, or hydration — think a decorative callout or a stats widget inside a form layout. A custom form field extends Filament\Forms\Components\Field and participates in the full form lifecycle: state binding, validation, hydration on edit, dehydration on save, and reactivity via ->live(). If your component needs to read or write a model attribute, you want a form field, not a component.
How do I bind Alpine state to a Filament field with wire:entangle?
Inside your field's Blade view, wrap the markup in <x-dynamic-component :component="$getFieldWrapperView()" :field="$field"> and give your interactive element an x-data that includes state: $wire.$entangle('{{ $getStatePath() }}'). The $getStatePath() helper returns the Livewire property name Filament has assigned to the field, and $wire.$entangle() keeps Alpine's local state and Livewire's public property in sync. Wrap the entangle call in $applyStateBindingModifiers() so modifiers like live, live(debounce: 500), and live(onBlur: true) apply automatically.
Can I use a Filament custom field inside an Action modal?
Yes — custom fields work in Actions, Action modals, Infolists, and standalone Livewire components without any extra wiring. The field is registered with Filament the moment you call MyField::make('column') inside the Action's schema, so the same field class can power the create form on a Resource and a one-off bulk-edit Action with no duplication.
How do I add validation rules to a Filament custom form field?
Chain ->rules([...]) on the field instance with any Laravel validation rule, exactly as you would for a TextInput. Inline closures work too — ->rules([fn () => 'required|in:red,green,blue']) — and ->required() is a shortcut for the most common case. Validation runs on form submission and on every ->live() interaction, returning errors through the standard field wrapper so the user sees them without any extra view code.
How do I publish the Blade view for a Filament custom field?
You don't need to publish anything when the field is your own — the view is created in your application's resources/views/filament/forms/components/ directory by make:filament-form-field and you edit it in place. If you want to override the view that the generator produces (for example to remove the default scaffold or to ship a different boilerplate across a team), publish Filament's stub set with php artisan vendor:publish --tag=filament-stubs and edit forms/Field.stub in the published directory.