Filament v3 Custom Field Plugins: Building Reusable Inputs with Full Form Integration
#filament #laravel #livewire #alpine

Filament v3 Custom Field Plugins: Building Reusable Inputs with Full Form Integration

3 min read Mohamed Said Mohamed Said

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, implement getView(), and use setUp() for defaults and validation rules.
  • Use $applyStateBindingModifiers with entangle — never hardcode wire: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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use the same custom field in both Filament v3 panels and standalone Livewire forms?
Filament fields are tightly coupled to Filament's ComponentContainer and state management. For standalone Livewire forms you would need to extract the Alpine component and Blade partial separately; the PHP Field class itself won't work outside a Filament form context.
Q02 How do I handle dehydration for complex values like arrays or objects?
Override `dehydrateState(array &$state): void` and `hydrateState(array &$state): void` on your Field subclass. Cast to/from JSON strings there, and add a matching Eloquent cast on the model so the database layer stays clean.
Q03 Should I use Filament's built-in asset management or a separate Vite build?
For a distributable package, compile your JS to a plain IIFE and publish it as a static vendor asset. Consumers shouldn't need to add your package to their Vite config. Reserve Vite integration for internal monorepo packages where you control the build pipeline.

Continue reading

More Articles

View all