Why Roll Your Own Filament Field?
Filament ships with a rich field library, but real projects inevitably need inputs the core doesn't cover — a color-swatch picker, a signature pad, a tag tokenizer backed by a custom API. Reaching for a third-party package every time creates version-lock risk. Understanding the field contract lets you build something maintainable, testable, and publishable as your own package.
This article walks through building a ColorSwatchField — simple enough to follow, complex enough to demonstrate every integration point.
The Field Contract
Every Filament field extends Filament\Forms\Components\Field, which itself extends Component. The minimum surface you must understand:
setUp()— configure default state, rules, and callbacks.getView()— return the Blade view string.- State hydration/dehydration — how Livewire round-trips your value.
namespace Acme\ColorSwatch;
use Filament\Forms\Components\Field;
class ColorSwatchField extends Field
{
protected string $view = 'color-swatch::color-swatch-field';
protected array $swatches = [];
protected function setUp(): void
{
parent::setUp();
$this->default(null);
$this->rule('nullable');
$this->rule('string');
$this->rule('max:7'); // #RRGGBB
}
public function swatches(array $colors): static
{
$this->swatches = $colors;
return $this;
}
public function getSwatches(): array
{
return $this->swatches;
}
}
The fluent swatches() method follows Filament's own builder pattern. Returning static keeps it chainable in form schemas.
The Blade View and Alpine Wiring
Filament fields render inside a Livewire component. Your view receives $getState(), $setState(), and $getId() as injected closures via the @php block Filament provides.
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="colorSwatch({
state: $wire.{{ $applyStateBindingModifiers("entangle('{$getStatePath()}')") }},
swatches: {{ Js::from($getSwatches()) }}
})"
x-init="init()"
class="flex gap-2 flex-wrap"
>
<template x-for="swatch in swatches" :key="swatch">
<button
type="button"
:style="`background:${swatch}`"
:class="state === swatch ? 'ring-2 ring-offset-2 ring-primary-500' : ''"
@click="state = swatch"
class="w-8 h-8 rounded-full border border-gray-300"
></button>
</template>
<input type="hidden" :value="state" />
</div>
</x-dynamic-component>
The critical line is $applyStateBindingModifiers("entangle('{$getStatePath()}')"). This is Filament's own helper — it respects deferred/lazy binding modes the form author may have configured, so your field behaves consistently with native fields.
Alpine Component Definition
Keep JS in a dedicated file loaded via your service provider's $this->callAfterResolving or a Vite entrypoint:
// resources/js/color-swatch.js
document.addEventListener('alpine:init', () => {
Alpine.data('colorSwatch', ({ state, swatches }) => ({
state,
swatches,
init() {
this.$watch('state', val => {
// Sync back if needed; entangle handles Livewire side
});
},
}));
});
Service Provider and Asset Registration
public function packageBooted(): void
{
// Using spatie/laravel-package-tools
Filament::serving(function () {
Filament::registerRenderHook(
PanelsRenderHook::HEAD_END,
fn () => Blade::render(
'<script src="{{ asset(\"vendor/color-swatch/color-swatch.js\") }}"></script>'
)
);
});
}
Publish the compiled JS via php artisan vendor:publish --tag=color-swatch-assets — keep the asset pipeline outside your package's Vite config so consumers don't inherit your build tooling.
Testing the Field with Pest
use Filament\Forms\ComponentContainer;
use Acme\ColorSwatch\ColorSwatchField;
it('stores a valid hex color', function () {
$field = ColorSwatchField::make('brand_color')
->swatches(['#FF0000', '#00FF00']);
$container = ComponentContainer::make(TestForm::make())
->components([$field])
->fill(['brand_color' => '#FF0000']);
expect($container->getState()['brand_color'])->toBe('#FF0000');
});
ComponentContainer lets you unit-test field state without booting a full Livewire component — fast and isolated.
Takeaways
- Extend
Field, implementgetView(), and usesetUp()for defaults and validation rules. - Use
$applyStateBindingModifierswithentangle— never hardcodewire:model. - Scope Alpine components with
Alpine.data()to avoid global namespace collisions. - Register assets via
Filament::serving()so they only load inside Filament panels. - Test with
ComponentContainer::make()for fast, isolated field unit tests.