Skip to content

Filament v3 Custom Field Plugins: Building a Reusable Signature Pad Component

Learn how to build a production-ready Filament v3 custom field plugin — a signature pad — covering Alpine.js state, Livewire entanglement, PHP-side casting, and package distribution.

Published · Updated · 3 min read

Filament v3 Custom Field Plugins: Building a Reusable Signature Pad Component

Why Build a Custom Filament Field Plugin?

Filament ships with a rich field library, but real-world SaaS products inevitably need domain-specific inputs — signature pads, rich colour pickers, geo-coordinate selectors. Understanding how to build one correctly means you can extend Filament without fighting its internals.

This article walks through a signature pad field: a canvas-based input that stores a base64 PNG, wired cleanly through Livewire and castable to a value object on the Eloquent model.


The Component Skeleton

Filament custom fields extend Filament\Forms\Components\Field. The minimum surface area is:

namespace Acme\SignaturePad;

use Filament\Forms\Components\Field;

class SignaturePad extends Field
{
    protected string $view = 'signature-pad::signature-pad';

    public function getDefaultState(): mixed
    {
        return null;
    }
}

Register the view namespace in your service provider:

public function boot(): void
{
    $this->loadViewsFrom(__DIR__.'/../resources/views', 'signature-pad');
}

Alpine.js + Livewire Entanglement

The tricky part is syncing canvas data back to Livewire's state. Filament uses $wire.entangle under the hood for its own fields, and you should too.

{{-- resources/views/signature-pad.blade.php --}}
<x-dynamic-component
    :component="$getFieldWrapperView()"
    :field="$field"
>
    <div
        x-data="signaturePad(@entangle($getStatePath()))" 
        x-init="init()"
    >
        <canvas
            x-ref="canvas"
            class="w-full border rounded-lg"
            height="200"
        ></canvas>

        <button type="button" x-on:click="clear()"
            class="mt-2 text-sm text-danger-600">
            Clear
        </button>
    </div>
</x-dynamic-component>

The Alpine component:

Alpine.data('signaturePad', (state) => ({
    state,
    pad: null,

    init() {
        const canvas = this.$refs.canvas;
        this.pad = new SignaturePad(canvas); // signature_pad npm package

        if (this.state) {
            this.pad.fromDataURL(this.state);
        }

        this.pad.addEventListener('endStroke', () => {
            this.state = this.pad.toDataURL();
        });
    },

    clear() {
        this.pad.clear();
        this.state = null;
    },
}));

The @entangle($getStatePath()) call binds the Alpine state property directly to Filament's Livewire component state path — no custom events needed.


PHP-Side: Validation and Casting

Add a validation rule to reject empty submissions when the field is required:

public function getValidationRules(): array
{
    return array_merge(parent::getValidationRules(), [
        $this->getStatePath() => [
            fn () => function (string $attribute, mixed $value, \Closure $fail) {
                if ($this->isRequired() && blank($value)) {
                    $fail('A signature is required.');
                }
                if ($value && !str_starts_with($value, 'data:image/png;base64,')) {
                    $fail('Invalid signature format.');
                }
            },
        ],
    ]);
}

On the model, a custom cast keeps the domain layer clean:

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class SignatureCast implements CastsAttributes
{
    public function get($model, string $key, mixed $value, array $attributes): ?Signature
    {
        return $value ? new Signature($value) : null;
    }

    public function set($model, string $key, mixed $value, array $attributes): array
    {
        return [$key => $value instanceof Signature ? $value->dataUrl : $value];
    }
}
// On the Eloquent model
protected $casts = [
    'signature' => SignatureCast::class,
];

Package Auto-Discovery

For distribution, declare the service provider in composer.json:

"extra": {
    "laravel": {
        "providers": [
            "Acme\\SignaturePad\\SignaturePadServiceProvider"
        ]
    }
}

Publish assets via the service provider:

public function boot(): void
{
    $this->loadViewsFrom(__DIR__.'/../resources/views', 'signature-pad');

    $this->publishes([
        __DIR__.'/../resources/dist' => public_path('vendor/signature-pad'),
    ], 'signature-pad-assets');
}

Users run php artisan vendor:publish --tag=signature-pad-assets once, then reference the JS in their Filament panel's renderHook or vite.config.js.


Takeaways

  • Extend Field, point to a Blade view, and let @entangle($getStatePath()) handle Livewire sync — no custom events.
  • Keep validation inside getValidationRules() so Filament's error display works automatically.
  • Use a dedicated Eloquent cast to keep raw base64 out of your domain objects.
  • Publish JS assets separately from views so consumers can version them independently.
  • Declare the service provider in composer.json extras for zero-config auto-discovery.

Frequently asked questions

How does @entangle work inside a Filament custom field view?

`@entangle($getStatePath())` compiles to a Livewire JS entangle call using the field's dot-notation state path. Any Alpine property bound to it stays in sync with the Livewire component's state without manual event dispatching.

Can I use this pattern for fields that store structured data instead of a string?

Yes. If your field stores JSON (e.g. coordinates), return an array from `getDefaultState()`, encode/decode in the cast, and ensure Alpine serialises the value to a JSON string before assigning it to the entangled state property.

Do I need to register the Alpine component globally or can it be scoped?

Filament loads Alpine after its own scripts, so you can register via `document.addEventListener('alpine:init', ...)` in a published JS asset, or inline the `Alpine.data` call in a `@push('scripts')` block in your Blade view.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project