Filament v4 Custom Field Plugins: Building a Reusable Signature Pad Component
#filament #laravel #livewire #plugins

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

3 min read Mohamed Said Mohamed Said

Building a Reusable Signature Pad Field for Filament v4

Filament v4's unified Schema API makes custom field plugins more composable than ever. Rather than fighting the framework with raw Blade hacks, you can ship a self-contained package that drops cleanly into any Form, Infolist, or Table schema. This walkthrough builds a real signature pad field — the kind you'd use in a contracts or onboarding flow — and covers every layer from asset bundling to state hydration.

Project Structure

src/
  SignaturePad.php          # Field class
  SignaturePadServiceProvider.php
resources/
  js/signaturePad.js        # Alpine component
  views/
    signature-pad.blade.php

Registering Assets the Right Way

Filament v4 uses FilamentAsset for scoped asset loading — assets are only injected when the component is actually rendered, not globally.

// SignaturePadServiceProvider.php
use Filament\Support\Assets\AlpineComponent;
use Filament\Support\Facades\FilamentAsset;

public function boot(): void
{
    FilamentAsset::register([
        AlpineComponent::make('signature-pad', __DIR__ . '/../dist/signature-pad.js'),
    ], package: 'acme/filament-signature-pad');

    $this->loadViewsFrom(__DIR__ . '/../resources/views', 'signature-pad');
}

Using AlpineComponent instead of a raw Js asset tells Filament to lazy-load and register the Alpine component automatically — no manual Alpine.data() call in your app.

The Field Class

// SignaturePad.php
namespace Acme\FilamentSignaturePad;

use Filament\Forms\Components\Field;

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

    protected bool $clearable = true;
    protected string $format = 'image/png';

    public function clearable(bool $condition = true): static
    {
        $this->clearable = $condition;
        return $this;
    }

    public function format(string $mimeType): static
    {
        $this->format = $mimeType;
        return $this;
    }

    public function getClearable(): bool
    {
        return $this->clearable;
    }

    public function getFormat(): string
    {
        return $this->format;
    }
}

Keep the field class thin. Business logic (e.g., converting the base64 payload to a stored file) belongs in a dedicated action or cast, not here.

The Blade View and Alpine Wiring

{{-- resources/views/signature-pad.blade.php --}}
<x-dynamic-component
    :component="$getFieldWrapperView()"
    :field="$field"
>
    <div
        x-data="signaturePad({
            state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')" ) }},
            clearable: {{ $getClearable() ? 'true' : 'false' }},
            format: '{{ $getFormat() }}'
        })"
        x-load-css="[]"
        x-load="[]"
        ax-load
        ax-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('signature-pad', 'acme/filament-signature-pad') }}"
    >
        <canvas x-ref="canvas" class="w-full border rounded-lg" height="200"></canvas>
        <template x-if="clearable">
            <button type="button" x-on:click="clear"
                class="mt-2 text-sm text-danger-600">Clear</button>
        </template>
    </div>
</x-dynamic-component>

The ax-load / ax-load-src pattern is Filament v4's lazy Alpine loader. It fetches the component script only when the DOM node is present.

The Alpine Component

// resources/js/signaturePad.js
export default function signaturePad({ state, clearable, format }) {
    return {
        state,
        clearable,
        drawing: false,
        init() {
            const canvas = this.$refs.canvas;
            const ctx = canvas.getContext('2d');
            canvas.addEventListener('mousedown', () => { this.drawing = true; ctx.beginPath(); });
            canvas.addEventListener('mousemove', (e) => {
                if (!this.drawing) return;
                const r = canvas.getBoundingClientRect();
                ctx.lineTo(e.clientX - r.left, e.clientY - r.top);
                ctx.stroke();
            });
            canvas.addEventListener('mouseup', () => {
                this.drawing = false;
                this.state = canvas.toDataURL(format);
            });
        },
        clear() {
            const canvas = this.$refs.canvas;
            canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
            this.state = null;
        }
    };
}

State is synced back to Livewire via $entangle on mouseup — not on every mousemove — keeping wire calls minimal.

Consuming the Field

use Acme\FilamentSignaturePad\SignaturePad;

SignaturePad::make('signature')
    ->clearable()
    ->format('image/svg+xml')
    ->required()
    ->columnSpanFull(),

Storing the Payload

The field stores a raw base64 data URL. Convert it on save using a model observer or a dedicated action:

// In your CreateRecord action or observer
$data['signature'] = (new StoreSignatureImage)($data['signature']);

Keep the conversion outside the field — the field's only job is capturing and surfacing state.

Key Takeaways

  • Use FilamentAsset::register with AlpineComponent for scoped, lazy asset loading.
  • ax-load / ax-load-src defers Alpine component hydration until the DOM node exists.
  • $entangle + $applyStateBindingModifiers is the canonical way to sync custom field state with Livewire.
  • Keep field classes thin; push persistence logic to actions or observers.
  • Publish views via loadViewsFrom so consumers can override templates without forking the package.

Found this useful?

Frequently Asked Questions

3 questions
Q01 How do I handle touch events for mobile signature capture?
Add `touchstart`, `touchmove`, and `touchend` listeners in your Alpine component alongside the mouse events. Extract the coordinates with `event.touches[0].clientX` and call `preventDefault()` on touchmove to stop page scrolling during drawing.
Q02 Can I validate the signature field to ensure it isn't empty?
Yes. Because the field stores a string (the data URL) or null, you can chain `->required()` on the field. For a more precise check — ensuring the canvas isn't just a blank white rectangle — add a custom Filament validation rule that inspects the data URL length or pixel entropy.
Q03 How do I make the component work inside a Filament Infolist for read-only display?
Create a companion `SignaturePadEntry` class extending `Filament\Infolists\Components\Entry` with its own Blade view that renders an `<img>` tag from the stored data URL. The field and entry are separate classes in Filament v4's unified Schema API.

Continue reading

More Articles

View all