Why Build a Filament v4 Field Plugin?
Filament v4 replaced the separate Forms\Components, Infolists\Components, and Tables\Columns hierarchies with a unified Schema API. Every renderable piece — form fields, infolist entries, table columns — now descends from Filament\Schemas\Components\Component. That single base class is your entry point when distributing a reusable field as a Composer package.
This article walks through building a ColourSwatchInput field that renders a native <input type="color"> with a hex-value label, packages it correctly, and wires up state hydration so Livewire round-trips work cleanly.
Scaffolding the Component Class
<?php
namespace Acme\FilamentColourSwatch\Schemas\Components;
use Filament\Schemas\Components\Field;
class ColourSwatchInput extends Field
{
protected string $view = 'filament-colour-swatch::components.colour-swatch-input';
protected bool $showHexLabel = true;
public function showHexLabel(bool $show = true): static
{
$this->showHexLabel = $show;
return $this;
}
public function getShowHexLabel(): bool
{
return $this->showHexLabel;
}
}
Field already handles $statePath, $label, validation rules, and the Livewire entangle contract. You only add domain-specific configuration.
The Blade View
{{-- resources/views/components/colour-swatch-input.blade.php --}}
<x-filament-schemas::components.field :field="$field">
<div class="flex items-center gap-3">
<input
type="color"
id="{{ $field->getId() }}"
{{ $applyStateBindingModifiers('wire:model') }}="{{ $field->getStatePath() }}"
class="h-10 w-16 cursor-pointer rounded border border-gray-300"
/>
@if ($field->getShowHexLabel())
<span
x-text="$wire.get('{{ $field->getStatePath() }}')"
class="font-mono text-sm text-gray-600"
></span>
@endif
</div>
</x-filament-schemas::components.field>
$applyStateBindingModifiers is provided by Filament's view data and automatically appends .live, .lazy, or .debounce based on the field's configuration — never hardcode wire:model.live yourself.
Service Provider and Auto-Discovery
<?php
namespace Acme\FilamentColourSwatch;
use Filament\Support\Assets\Css;
use Filament\Support\Facades\FilamentAsset;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
class FilamentColourSwatchServiceProvider extends PackageServiceProvider
{
public function configurePackage(Package $package): void
{
$package
->name('filament-colour-swatch')
->hasViews();
}
public function packageBooted(): void
{
FilamentAsset::register([
Css::make('filament-colour-swatch', __DIR__ . '/../dist/colour-swatch.css'),
], 'acme/filament-colour-swatch');
}
}
In composer.json, register the provider under extra.laravel.providers for zero-config auto-discovery:
{
"extra": {
"laravel": {
"providers": [
"Acme\\FilamentColourSwatch\\FilamentColourSwatchServiceProvider"
]
}
}
}
State Hydration and Default Values
Filament hydrates field state from the model or the form's fill() call. Because ColourSwatchInput extends Field, this is automatic. However, if you need a sensible default when the model attribute is null, override setUp:
protected function setUp(): void
{
parent::setUp();
$this->default('#000000');
$this->rule('regex:/^#[0-9A-Fa-f]{6}$/');
}
Do not override getState() unless you need to transform the raw Livewire value — Filament's dehydration pipeline already handles casting through your model's Eloquent casts.
Using the Field in a Resource
use Acme\FilamentColourSwatch\Schemas\Components\ColourSwatchInput;
public static function form(Schema $schema): Schema
{
return $schema->components([
ColourSwatchInput::make('brand_colour')
->label('Brand Colour')
->showHexLabel()
->required(),
]);
}
Because the Schema API is unified, the same component class can appear in a form, an infolist (read-only via ->disabled()), or even a table filter — no duplication across namespaces.
Takeaways
- Extend
Filament\Schemas\Components\Field— not the old v3 namespace — for full v4 compatibility. - Use
$applyStateBindingModifiersin Blade instead of hardcodingwire:modelmodifiers. - Register assets via
FilamentAsset::register()insidepackageBooted(), notregister(). - Auto-discovery via
extra.laravel.providersmeans consumers need zero manual setup. - Override
setUp()for defaults and validation rules; avoid touching hydration internals unless truly necessary. - The unified Schema API means one component class works across forms, infolists, and filters.