valentin-morice/filament-sketchpad
A Filament form field that adds a sketchpad canvas for drawing or capturing signatures with mouse and touch. Save sketches as images, customize brush and canvas settings, and integrate quickly into Laravel Filament forms and resources.
Install and register the field
composer require valentin-morice/filament-sketchpad
The package ships a single form component (ValentinMorice\FilamentSketchpad\Forms\Components\Sketchpad) that renders an HTML canvas and pushes the drawing into Livewire state as a base64 PNG data URL (data:image/png;base64,...). There is no panel plugin to register — you use it like any other Filament field.
First steps, in order
height(), width(), penColor(), backgroundColor(), clearButton() — names vary by release; confirm against src/Forms/Components/Sketchpad.php).longText, not string:$table->longText('signature')->nullable();
protected $fillable = ['signature'];
use ValentinMorice\FilamentSketchpad\Forms\Components\Sketchpad;
Sketchpad::make('signature')
->label('Customer signature')
->required();
First real use case: signature capture on a contract/consent form, or a quick annotation on a job/ticket record. That is where this package pays for itself — you avoid pulling in a full JS canvas dependency and get a Filament-native field in ~3 lines.
Keep the base64 string in the column and decode at render time. This is the path of least resistance and what the package is designed around.
Sketchpad::make('signature')
->required()
->dehydrated(); // default
Render it read-only with a custom view or an ImageColumn/ViewField that unwraps the prefix. In an infolist:
use Filament\Infolists\Components\ImageEntry;
ImageEntry::make('signature')
->getStateUsing(fn ($record) => $record->signature) // data URL works in <img src> as-is
->height(120);
Because a data: URI is valid in <img src>, you often do not need to touch the filesystem at all.
If you need a real file (PDF embedding, S3, virus scanning, signed URLs), intercept the state on save:
Sketchpad::make('signature')
->dehydrateStateUsing(function (?string $state) {
if (blank($state)) {
return null;
}
$data = preg_replace('#^data:image/\w+;base64,#i', '', $state);
$path = 'signatures/' . uuid_create() . '.png';
Storage::disk('private')->put($path, base64_decode($data));
return $path; // store the path, not the blob
});
Then expose it on the model:
public function signatureUrl(): ?string
{
return $this->signature
? Storage::disk('private')->temporaryUrl($this->signature, now()->addMinutes(10))
: null;
}
Base64 payloads can be spoofed, so validate size/shape server-side rather than trusting the widget:
Sketchpad::make('signature')
->rules([
'nullable',
'string',
'max:1048576', // ~1MB of base64 text
function (string $attribute, $value, $fail) {
if ($value && ! preg_match('#^data:image/png;base64,#', $value)) {
$fail('Invalid signature payload.');
}
},
]);
Do the same on the model/observer if records can be created outside Filament.
For "sign off each line item" forms, put the field inside a Repeater and give each item a stable key so Livewire doesn't reuse canvas state between rows:
Repeater::make('signoffs')
->schema([
TextInput::make('name')->required(),
Sketchpad::make('signature')->required(),
])
->collapsible()
->itemLabel(fn (array $state): ?string => $state['name'] ?? null);
Verify on your Filament version that the canvas re-initialises on add/delete of rows — that is the most common bug class with canvas fields in repeaters, so smoke-test it before shipping.
On EditRecord, the field must hydrate from the stored data URL (Pattern 1) or from a fetched file (Pattern 2). For Pattern 2 you need a formatStateUsing that converts the path back into a data URL:
->formatStateUsing(fn (?string $state) => $state
? 'data:image/png;base64,' . base64_encode(Storage::disk('private')->get($state))
: null
)
If the field cannot render an existing value, users will silently overwrite signatures on every save — treat this as the acceptance test for your integration.
Once you have a path (Pattern 2), embedding into a DomPDF/mPDF contract is trivial:
$signature = Storage::disk('private')->path($record->signature);
// <img src="{{ $signature }}"> or $pdf->image($signature, ...)
Attach to a notification with ->attachData(base64_decode($data), 'signature.png', ['mime' => 'image/png']) if you stayed on Pattern 1.
TemporaryUploadedFile. Do not reach for FileUpload helpers (saveUploadedFileUsing, disk(), directory()) unless the README explicitly lists them. Decode with preg_replace('#^data:image/\w+;base64,#', '', $state) and base64_decode().string columns truncate silently. A drawn signature can exceed 255 chars easily. Use longText/text. If you see a blank signature after save, check the column type before blaming the JS.post_max_size on multi-signature forms. Cap canvas dimensions and pen width.Select that toggles field visibility) risks wiping an in-progress drawing. Wrap dependent sections so the sketchpad isn't re-mounted, or test with ->live() toggles explicitly.required() on an empty canvas. The field starts blank and may dehydrate as null or ''. Confirm which, because required + nullable behave differently and can block submission with no visible feedback.rules() closure (Pattern 3) or a model mutator/observer that rejects non-PNG data URLs.->dehydrated(false) toggle, verify the stored value is actually nulled rather than leaving stale base64 on the record.<img>. Set an explicit backgroundColor() (white) at capture time so exports/PDFs are readable.Livewire::test(YourForm::class)->fillForm(['data' => ['signature' => $fixtureDataUrl]])->call('save') with a small hard-coded data URL fixture. You cannot simulate drawing in PHPUnit, so assert persistence/decoding logic, not the canvas.php artisan vendor:publish tags before forking.composer.json constraints; a Filament major bump may break field API compatibility without a corresponding release.How can I help you explore Laravel packages today?