Search

Building a Custom Filament Form Field from Scratch

Building a Custom Filament Form Field from Scratch

Key takeaways

  • ✓ php artisan make:filament-form-field RatingInput creates a class that extends Filament\Forms\Components\Field and a Blade view.
  • ✓ Wrap your markup in $getFieldWrapperView() so you get the label, helper text and errors for free. Bind state with $applyStateBindingModifiers() so live() and live(onBlur: true) keep working.
  • ✓ Configuration methods should accept Closure values and go through $this->evaluate(). That’s what makes them feel native.
  • ✓ Fields aren’t Livewire components. To call PHP from your field’s JavaScript, mark a method with #[ExposedLivewireMethod].

Filament ships a wide range of form fields, but every real project eventually needs one that doesn’t exist. It might be a star rating, a colour-swatch picker, a map, or a “check availability” input that calls an API. You could put a ViewField together in ten minutes, but a proper custom field class is reusable, configurable, testable and easy to publish as a plugin later.

Also Read: Filament vs Building Your Own Admin Panel: When Is It Worth It?

In this tutorial we’ll build a RatingInput field from scratch, then add a second, server-backed feature using Filament’s #[ExposedLivewireMethod] attribute.

What we’re building

RatingInput::make('satisfaction')
    ->label('Customer satisfaction')
    ->maxStars(5)
    ->required()

It shows clickable stars, supports hover preview and clearing, works in dark mode, respects disabled() and live(), validates on the server, and stores an integer.

Step 1: Generate the field

php artisan make:filament-form-field RatingInput

This creates two files:

// app/Filament/Forms/Components/RatingInput.php
namespace App\Filament\Forms\Components;

use Filament\Forms\Components\Field;

class RatingInput extends Field
{
    protected string $view = 'filament.forms.components.rating-input';
}

…and resources/views/filament/forms/components/rating-input.blade.php.

Also Read: Laravel: Best Filament Themes

The custom fields docs make one important point up front: “Filament form fields are not Livewire components.” Public properties on your field class don’t magically appear in the view. You expose data through public methods, and Filament makes them available in Blade as $getSomething() functions.

Step 2: Add configuration methods

A native-feeling configuration method accepts a static value or a closure, and resolves it with evaluate(), which gives users utility injection ($record, $get, $operation and so on) for free:

namespace App\Filament\Forms\Components;

use Closure;
use Filament\Forms\Components\Field;

class RatingInput extends Field
{
    protected string $view = 'filament.forms.components.rating-input';

    protected int | Closure $maxStars = 5;

    protected bool | Closure $isClearable = true;

    protected function setUp(): void
    {
        parent::setUp();

        $this->default(0);

        // Store an int, whatever the browser sends
        $this->dehydrateStateUsing(fn ($state): int => (int) $state);
    }

    public function maxStars(int | Closure $count): static
    {
        $this->maxStars = $count;

        return $this;
    }

    public function clearable(bool | Closure $condition = true): static
    {
        $this->isClearable = $condition;

        return $this;
    }

    public function getMaxStars(): int
    {
        return max(1, (int) $this->evaluate($this->maxStars));
    }

    public function isClearable(): bool
    {
        return (bool) $this->evaluate($this->isClearable);
    }

    public function getValidationRules(): array
    {
        return [
            ...parent::getValidationRules(),
            'integer',
            'min:0',
            'max:' . $this->getMaxStars(),
        ];
    }
}

A few design choices worth copying:

  • setUp() for defaults, not make(). Since v4, overriding make() is discouraged. setUp() runs right after construction and is the supported extension point.
  • Server-side validation lives in the class. getValidationRules() merges your rules with whatever the user adds (->required(), ->rules([...])), so a user can’t send rating=9999 through the browser’s dev tools.
  • Dehydration casts the value. Alpine may send a string. The database gets an integer.

Step 3: Write the Blade view

{{-- resources/views/filament/forms/components/rating-input.blade.php --}}
@php
  $statePath = $getStatePath();
  $maxStars = $getMaxStars();
  $disabled = $isDisabled();
@endphp

<x-dynamic-component :component="$getFieldWrapperView()" :field="$field">
  <div
    x-data="{
      state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
      hover: 0,
      select(value) {
        if (@js($disabled)) return
        this.state = (this.state === value && @js($isClearable())) ? 0 : value
      },
    }"
    
    role="radiogroup"
    aria-label="{{ $getLabel() }}"
  >
    @for ($i = 1; $i <= $maxStars; $i++)
      <button
        type=button
        role="radio"
        x-bind:aria-checked="state === {{ $i }}"
        aria-label="{{ $i }} {{ str('star')->plural($i) }}"
        x-on:click="select({{ $i }})"
        x-on:mouseenter="hover = {{ $i }}"
        x-on:mouseleave="hover = 0"
        @disabled($disabled)
        
        x-bind:class="(hover || state) >= {{ $i }}
          ? 'text-amber-400'
          : 'text-gray-300 dark:text-gray-600'"
      >★</button>
    @endfor

    <span
      
      x-text="state ? `${state} / {{ $maxStars }}` : 'Not rated'"
    ></span>
  </div>
</x-dynamic-component>

Here’s what each piece does:

  • $getFieldWrapperView() renders Filament’s standard wrapper: label, required asterisk, hint, helper text and validation errors. Leave it out and your field looks foreign.
  • $getStatePath() is the Livewire property the field is bound to, for example data.satisfaction.
  • $applyStateBindingModifiers(...) applies .live, .live.debounce or blur behaviour when a developer calls ->live() on your field. If you hard-code $wire.$entangle(...), reactivity silently breaks.
  • @js() safely passes PHP values into Alpine expressions.

Make sure Tailwind sees your classes

Since Filament v4, your own Tailwind classes only compile through a custom theme. If the stars have no colour, add your view path to theme.css:

@source '../../../../resources/views/filament/**/*';

Then run npm run build. Our Filament theming guide covers setting up a theme from scratch.

Step 4: Use it

use App\Filament\Forms\Components\RatingInput;

RatingInput::make('satisfaction')
    ->label('Customer satisfaction')
    ->maxStars(fn (?Survey $record) => $record?->scale ?? 5)
    ->clearable(false)
    ->required()
    ->helperText('Click a star again to clear it.'),

Everything Filament fields normally support works without extra code: ->required(), ->disabled(), ->hidden(), ->live(), ->afterStateUpdated(), ->columnSpan(), ->hint(). They all come from the base Field class you extended.

Also Read: Laravel: Filament Blueprint Review

Show the value in tables and infolists with existing components:

TextColumn::make('satisfaction')
    ->formatStateUsing(fn (int $state) => str_repeat('★', $state) ?: '–')
    ->color('warning');

Step 5: Call the server from your field

Some fields need the backend: geocoding an address, checking whether a username is taken, or previewing a slug. Filament v5 supports this with the #[ExposedLivewireMethod] attribute. Only methods marked this way can be called from JavaScript, which is a sensible security default.

Here’s a small UsernameInput that checks availability as you type:

namespace App\Filament\Forms\Components;

use App\Models\User;
use Filament\Forms\Components\TextInput;
use Filament\Support\Components\Attributes\ExposedLivewireMethod;
use Livewire\Attributes\Renderless;

class UsernameInput extends TextInput
{
    protected string $view = 'filament.forms.components.username-input';

    #[ExposedLivewireMethod]
    #[Renderless]
    public function checkAvailability(string $username): bool
    {
        return strlen($username) >= 3
            && ! User::where('username', $username)->exists();
    }
}
{{-- resources/views/filament/forms/components/username-input.blade.php --}}
@php($key = $getKey())

<x-dynamic-component :component="$getFieldWrapperView()" :field="$field">
  <div
    x-data="{
      state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }},
      available: null,
      async check() {
        this.available = await $wire.callSchemaComponentMethod(
          @js($key), 'checkAvailability', { username: this.state ?? '' },
        )
      },
    }"
    
  >
    <x-filament::input.wrapper>
      <x-filament::input type=text x-model="state" x-on:input.debounce.400ms="check" />
    </x-filament::input.wrapper>

    <p x-show="available === true" >Available</p>
    <p x-show="available === false" >Taken or too short</p>
  </div>
</x-dynamic-component>

#[Renderless] stops the call from re-rendering the whole form. Filament passes the arguments object to your method by name. The availability check is only a UX hint, so still add a real unique rule to the field for validation.

Step 6: Heavy JavaScript? Load it asynchronously

If your field wraps a library (a map, a date-range picker, a code editor), don’t load it on every page. Filament’s asset system can register an async Alpine component that loads only when the field renders. That keeps the panel’s initial JavaScript small.

Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work - Laravel

Step 7: Test it

Custom fields work with Filament’s normal schema testing helpers:

use function Pest\Livewire\livewire;

it('saves a star rating', function () {
    livewire(CreateSurvey::class)
        ->fillForm(['title' => 'Q3 NPS', 'satisfaction' => 4])
        ->call('create')
        ->assertHasNoFormErrors();

    expect(Survey::first()->satisfaction)->toBe(4);
});

it('rejects ratings above the maximum', function () {
    livewire(CreateSurvey::class)
        ->fillForm(['title' => 'Q3 NPS', 'satisfaction' => 9])
        ->call('create')
        ->assertHasFormErrors(['satisfaction' => 'max']);
});

These tests exercise the server contract (state, validation, dehydration). Test the Alpine behaviour with a browser test if it’s critical. See testing Filament panels with Pest.

Packaging it as a plugin

When the field proves itself, move it into a package:

  • Namespace the view (my-plugin::rating-input) and register it in a service provider.
  • Register CSS and JS with FilamentAsset::register().
  • Document which Filament majors you support. Most plugins today declare filament/forms: ^4.0|^5.0, which works because the field API is identical in v4 and v5.

The build a standalone plugin guide covers the package skeleton.

Common mistakes

  1. Public properties instead of getters. The view can’t see them. Add a getX() method.
  2. Hard-coded wire:model. Use $applyStateBindingModifiers() or live() stops working.
  3. Validation only in Alpine. Browser checks are cosmetic. Put the rules in getValidationRules().
  4. No field wrapper. Labels, errors and helper text disappear.
  5. Tailwind classes that never compile. Add a custom theme with the right @source.

FAQ

How do I create a custom field in Filament v5?

Run php artisan make:filament-form-field Name. Add configuration methods to the generated class that extends Filament\Forms\Components\Field, then build the Blade view inside $getFieldWrapperView() and bind state with $applyStateBindingModifiers().

Also Read: What Is Jev? TypeSafe AI's 'System One' Model Explained (Pricing, API & Use Cases)

Can a custom Filament field call PHP methods?

Yes. Add #[ExposedLivewireMethod] to a public method on the field class, and call it from Alpine with $wire.callSchemaComponentMethod(key, 'method', args). Add #[Renderless] if the UI doesn’t need to re-render.

When should I use ViewField instead of a custom field class?

For one-off markup in a single form. Create a class when you’ll reuse the field, need configuration methods, or want built-in validation.

Do custom fields written for Filament v4 work in v5?

Generally yes. The field API didn’t change. Check that your views don’t use Livewire 3-only patterns, such as unclosed Livewire tags or wire:model.blur without .live.

Sources and further reading

Related on The Web Tier: Custom table columns in Filament

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy