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::registerwithAlpineComponentfor scoped, lazy asset loading. ax-load/ax-load-srcdefers Alpine component hydration until the DOM node exists.$entangle+$applyStateBindingModifiersis the canonical way to sync custom field state with Livewire.- Keep field classes thin; push persistence logic to actions or observers.
- Publish views via
loadViewsFromso consumers can override templates without forking the package.