Filament v4 Custom Field Plugins: Building Reusable Schema Components
#filament #laravel #filament-v4 #packages #livewire

Filament v4 Custom Field Plugins: Building Reusable Schema Components

1 min read Mohamed Said Mohamed Said

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 $applyStateBindingModifiers in Blade instead of hardcoding wire:model modifiers.
  • Register assets via FilamentAsset::register() inside packageBooted(), not register().
  • Auto-discovery via extra.laravel.providers means 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a Filament v4 custom field plugin also work as an infolist entry?
Yes. Because all schema components share the same base class in v4, you can render your field in an infolist by calling `->disabled()` or by checking `$field->isDisabled()` in the Blade view and switching to a read-only display. No separate Entry class is required.
Q02 How do I test a custom Filament v4 field plugin with Pest?
Use `livewire(YourResource\Pages\CreateRecord::class)->fillForm(['brand_colour' => '#ff0000'])->assertFormFieldExists('brand_colour')->assertHasNoFormErrors()`. Filament's Pest helpers work against the unified Schema API, so no special setup is needed for custom fields.
Q03 Should I publish the field's Blade view for end-user customisation?
Only if you expect consumers to need deep visual changes. Prefer exposing fluent configuration methods (like `showHexLabel()`) and keeping the view internal. Publishable views create a maintenance burden when you ship breaking view changes in minor releases.

Continue reading

More Articles

View all