savannabits/filament-signature-pad
Filament Signature Pad adds a canvas-based signature field to Filament forms, letting users draw with mouse or touch and save signatures as images. Easy to install and configure, it fits smoothly into admin panels for approvals, contracts, and sign-off workflows.
Install the field through Composer alongside your existing Filament v3 app:
composer require savannabits/filament-signature-pad
php artisan filament:assets # if the package registers assets
There is no panel plugin to register — SignaturePad is a form component you use directly:
use Savannabits\SignaturePad\Forms\Components\SignaturePad;
SignaturePad::make('signature')
->required();
The value that lands in your model is a base64 data URI string (data:image/png;base64,...). That means the column should be longText (or text on Postgres), and your first working use case is the classic one: a "sign here" box on a contract/consent/delivery form where you render the result back with a plain <img>:
// migration
$table->longText('signature')->nullable();
// blade / infolist
<img src="{{ $record->signature }}" alt="Signature" class="h-24" />
Where to look first: open src/Forms/Components/SignaturePad.php in vendor and read the fluent setters before you build anything. The setters are a thin wrapper over szimek/signature_pad options (penColor, dotSize, minWidth, maxWidth, backgroundColor, canvas height, clear-button label). A two-minute read saves you guessing at method names, and the Blade view next to it shows exactly what the Alpine component does on mount and on wire:model sync.
Keep the data URI, or convert at dehydrate time. Storing raw base64 in the DB is fine for low volume and is the zero-friction path. When you want real files, intercept the state on save:
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
SignaturePad::make('signature')
->dehydrateStateUsing(function (?string $state) {
if (! $state || ! str_starts_with($state, 'data:image')) {
return $state; // editing: already a path, don't destroy it
}
$encoded = Str::after($state, ',');
$path = 'signatures/' . Str::uuid() . '.png';
Storage::disk('private')->put($path, base64_decode($encoded));
return $path;
});
If you take that route you must also hydrate the field when editing, otherwise the canvas comes back empty and the user re-signs. Either add a ->formatStateUsing() that re-encodes the file back to a data URI for display, or split the concerns: keep the base64 in a dedicated column and copy it to a file in an observer.
Sign inside an action modal. This is where the field earns its keep — a "Capture signature" table action with no dedicated page:
use Filament\Tables\Actions\Action;
use Filament\Forms\Components\SignaturePad;
Action::make('sign')
->form([
SignaturePad::make('signature')->required(),
Forms\Components\TextInput::make('signed_by')->required(),
])
->action(fn (array $data, Order $record) => $record->update($data))
Two-party agreements. Two named fields (customer_signature, agent_signature) on one form is simpler and easier to validate than a repeater; a repeater of signatures works but each canvas mounts its own Alpine instance, so keep the row count small and test reordering.
Embedding in PDFs. dompdf/snappy accept data URIs in <img src> directly, so the stored value drops straight into a Blade PDF template with no disk round-trip. Set an explicit backgroundColor first — a transparent PNG over a white PDF page is fine, but over a coloured letterhead it disappears.
Display in tables/infolists. An ImageColumn will render the data URI if you point it at the state, since Filament just writes it into src; otherwise use a TextColumn with a custom view and an <img>. Do not pass a relative storage path to an ImageColumn expecting it to resolve — it won't touch the private disk.
Testing. Bypass the browser entirely by setting state to a fixture data URI:
Livewire::test(ContractForm::class)
->fillForm(['signature' => 'data:image/png;base64,' . base64_encode($png)])
->call('save')
->assertHasNoFormErrors();
required(). canvas.toDataURL() happily returns a valid PNG of an empty canvas, so ->required() sees a non-empty string. Add a custom rule that rejects the known blank-image hash, or check decoded byte length against your minimum.backgroundColor on the canvas so it looks like paper in both modes.TEXT is 64 KB. A detailed signature PNG at high DPI can exceed it; use LONGTEXT and set maxWidth/dotSize conservatively. Base64 also inflates payloads ~33%, which matters because the whole string round-trips through Livewire on every request — watch post_max_size and max_input_vars if the field sits in a large form.php artisan filament:assets && php artisan view:clear.composer.json constraint before upgrading the panel and pin filament/filament to ^3.0 if needed. With zero dependents on the registry, you are the QA — wrap the field in your own subclass (class Signature extends SignaturePad {}) so a swap to a hand-rolled Alpine component later is a one-file change. The underlying signature_pad integration is genuinely small; if you need tight control over stroke smoothing or SVG output, reimplementing is a couple of hours' work.->view() lets you point at your own Blade template if you need a different clear button or a "type your name" fallback; subclassing gives you getView() override. There is no server-side validation hook beyond standard Filament rules, so all business rules (who may sign, when) belong in ->visible()/->disabled() conditions driven by record state.How can I help you explore Laravel packages today?