Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Signature Pad Laravel Package

coolsam/signature-pad

A Laravel package for adding an HTML5 signature pad to forms, letting users draw signatures by mouse or touch and submit them as images. Integrates with Blade views and controllers for capturing and storing signatures.

View on GitHub
Deep Wiki
Context7

Getting Started

Validate provenance before the first require. The Composer vendor (coolsam) does not match the repo owner (bradwarwick), stars are effectively zero, and dependents are zero. That's not a reason to avoid it, but it is a reason to spend ten minutes confirming you're pulling the code you actually reviewed:

composer show --all coolsam/signature-pad   # confirm source URL, license, latest tag

If the source doesn't point at the repo you read, stop and vendor the code in instead of trusting the Composer channel.

Install and pin.

composer require coolsam/signature-pad

Then freeze the exact version in composer.json ("coolsam/signature-pad": "1.x.y"). A silent upstream patch changing how the field syncs to Livewire is a real risk when there's one maintainer and no dependents.

Read these three files before writing any app code — they answer what the README doesn't:

  1. vendor/coolsam/signature-pad/src/ — the field class and its exact FQCN. The docs call it a "Blade component"; for most Laravel devs the real value is as a Filament form field, and the namespace is the thing you need.
  2. The Blade view it renders — where signature_pad is instantiated and where the x-data/x-model→Livewire wiring lives. This is the file you'll read when a signature vanishes.
  3. The package's own composer.json — its illuminate/* and filament/* constraints. A conflict against your Filament major is the most likely reason this never leaves the spike branch; find out in the first hour.

Confirm the assets actually load. If your panel uses a custom Vite theme, open the form and draw a stroke. A rendered-but-dead canvas (static grey rectangle) means the package's JS isn't in the bundle — register it from your wrapper field's setUp() if so.

First use case: a nullable path column, one field. Start with an internal acknowledgement inside an existing Filament resource — delivery confirmation, equipment checkout, policy acceptance. Not contracts or waivers in week one.

Schema::table('deliveries', function (Blueprint $table) {
    $table->string('signature_path')->nullable()->after('delivered_at');
    $table->timestamp('signed_at')->nullable();
    $table->foreignId('signed_by')->nullable()->constrained('users');
});
use Coolsam\SignaturePad\Forms\Components\SignaturePad; // verify namespace from vendor/

SignaturePad::make('signature')
    ->label('Customer signature')
    ->columnSpanFull()
    ->required(),

Convert the data URL to a file on save (next section) — never store raw base64 in a column.

Implementation Patterns

1. Disk + path, never base64 in a column

The field hands you a base64 data URL in Livewire state. Persisting it directly means a longText column that bloats every select * by 5–50 KB per row and hydrates a blob into every model. Decode once on write, keep a path.

protected function mutateFormDataBeforeCreate(array $data): array
{
    $data['signature_path'] = $this->storeSignature($data['signature'] ?? null);
    $data['signed_at']       = filled($data['signature'] ?? null) ? now() : null;
    $data['signed_by']       = auth()->id();
    unset($data['signature']); // raw payload never reaches the model

    return $data;
}

protected function storeSignature(?string $dataUrl): ?string
{
    if (blank($dataUrl)) {
        return null;
    }

    if (! preg_match('#^data:image/png;base64,#', $dataUrl)) {
        throw ValidationException::withMessages(['data.signature' => 'Invalid signature payload.']);
    }

    $binary = base64_decode(substr($dataUrl, strpos($dataUrl, ',') + 1), true);

    if ($binary === false || strlen($binary) > 512_000 || @imagecreatefromstring($binary) === false) {
        throw ValidationException::withMessages(['data.signature' => 'Signature could not be read.']);
    }

    $path = 'signatures/'.Str::uuid().'.png';   // server-generated name, never client-supplied
    Storage::disk('private')->put($path, $binary);

    return $path;
}

imagecreatefromstring is doing real work: it verifies PNG magic bytes and decodability in one step, so a tampered payload can't land on disk mislabelled. Do the same conversion on mutateFormDataBeforeSave for edit forms, and delete the replaced file.

2. Wrap the field in your own class on day one

Given the maintenance profile, this is the highest-leverage move. Your codebase should depend on App\Filament\Forms\Components\Signature, never the vendor FQCN.

namespace App\Filament\Forms\Components;

use Coolsam\SignaturePad\Forms\Components\SignaturePad;

class Signature extends SignaturePad
{
    protected function setUp(): void
    {
        parent::setUp();

        $this
            ->columnSpanFull()
            ->rule('nullable')
            ->rule('string')
            ->rule('max:700000')
            ->rule(function (string $attribute, $value, \Closure $fail) {
                if (filled($value) && ! str_starts_with($value, 'data:image/png;base64,')) {
                    $fail('The signature must be a PNG image.');
                }
            });
    }
}

If the package breaks on a Filament major, or you swap the underlying JS library, you edit one class. If assets need registering, this is the seam.

3. If the signature is evidence, store the context around it

A drawn mark alone is weak. Capture who/where/when next to the pixels:

Schema::create('signatures', function (Blueprint $table) {
    $table->id();
    $table->morphs('signable');          // the delivery, acknowledgement, form
    $table->string('path');
    $table->foreignId('user_id')->nullable()->constrained();
    $table->string('ip_address', 45)->nullable();
    $table->string('user_agent')->nullable();
    $table->timestamp('signed_at');
    $table->string('content_hash', 64);  // sha256 of the PNG binary
    $table->timestamps();
});

sha256 costs nothing now and gives tamper detection later. User ID + IP + timestamp costs three columns and is the difference between "a PNG exists" and "this person was here at this time." Neither makes it legally binding — write that boundary down.

4. Blade component for non-Filament surfaces

The package also exposes a plain Blade component. Use it for the same class of workflow only — an authenticated, staff-facing form:

<x-signature-pad name="signature" pen-color="#111827" />

Read the payload back with the same validation gate. Don't let the Blade path become a hole that trusts raw base64 because it skipped the Filament form.

5. Null-safe downstream consumers

Every PDF renderer, export, or list view must tolerate pre-rollout records and optional-signature records:

@if ($delivery->signature_path)
    <img src="{{ Storage::disk('private')->temporaryUrl($delivery->signature_path, now()->addMinutes(5)) }}"
         alt="Signature captured {{ $delivery->signed_at->toFormattedDateString() }}">
@else
    <span class="text-gray-500">No signature on file</span>
@endif

Private disk plus temporary URLs, always — a signature is PII.

6. Test without a canvas

The field state is just a string, so Livewire form tests work fine and should be your default:

livewire(CreateDelivery::class)
    ->fillForm(['signature' => 'data:image/png;base64,'.base64_encode($this->pngFixture())])
    ->call('create')
    ->assertHasNoFormErrors();

Storage::disk('private')->assertExists(Delivery::first()->signature_path);

Also write the negative cases: non-PNG data URL, oversized payload, truncated base64, and empty payload (canvas touched and cleared — it may submit a blank white PNG, not null).

Gotchas and Tips

The vanishing signature. This is the failure that will burn you, and it's the one the package is least likely to have solved. Canvas content is DOM; the value only reaches the server when Alpine syncs to Livewire state. With deferred binding, a drawn signature followed by a validation failure elsewhere in the form can round-trip back blank — the user's mark is gone and they're staring at an empty box. Test this exact path explicitly: draw, submit with an intentional error on another field, confirm the signature survives. If it doesn't, disable deferred binding on that form or persist state aggressively. Don't discover this in production with a customer at a loading dock.

Blank canvas is not empty. A user who taps the canvas and walks away can submit a valid, decodable, all-white PNG. Guard server-side by sampling the decoded image for non-background pixels, or client-side via signaturePad.isEmpty() before sync. Otherwise you accumulate "signed" records with nothing on them.

Column sizing is a trap both ways. Base64 inflates binary ~33%, and signatures run 5–50 KB — but an unconstrained canvas on a high-DPI tablet can push far past that. varchar(255) truncates silently and yields a corrupt image at read time; longText bloats every query. The disk-plus-path pattern makes the problem disappear. If you must inline, cap server-side and constrain canvas dimensions (~600×200 logical px) so a 4K tablet doesn't produce a payload an order of magnitude larger than a phone.

Never trust the client, including filenames. The data URL has no filename, but the moment someone adds an upload fallback they'll wire up getClientOriginalName(). Generate names with Str::uuid() and validate image/png by magic bytes, not declared MIME.

Test ->disabled() and ->dehydrated(false) explicitly. A disabled canvas should show the existing signature but submit nothing; a dehydrated(false) field shouldn't appear in $data at all. Both behave differently across Filament minors. Read non-dehydrated values via $this->form->getState() or the component's getState() rather than $data.

Repeaters and wizards are where it breaks. Alpine components re-mount when a repeater row is added or a wizard step changes. Canvas fields are the most common casualty: the drawing disappears on step-back, or two rows share state. Test one repeater and one multi-step wizard during the spike — if either misbehaves, restrict the field to single-step, non-repeating forms.

Accessibility is a launch gate, not a backlog item. A draw-only canvas is unusable by keyboard and opaque to screen readers. If any audience is EU-based, the European Accessibility Act makes a typed-name or image-upload alternative a requirement. Build it as a sibling field behind a "can't sign by drawing?" toggle writing to the same column contract, so downstream code doesn't care which path was used.

Filament majors will break this silently. Third-party form fields are the most fragile surface across majors. The failure mode isn't an exception — the form renders fine and the field is simply absent. Put a smoke test like livewire(...)->assertFormExists('signature') in CI so an upgrade failure is a red build, not a form that quietly stopped capturing signatures. Add a review ticket to every Filament major.

Assets vs. Vite custom themes. With a custom theme the field's JS may not be in the compiled bundle; the symptom is a canvas that renders as a static grey rectangle with no drawing behaviour. Check @vite output and register the package's assets from your wrapper's setUp().

Watch the underlying JS library separately. This wrapper is thin over signature_pad; the capability lives there and its maintenance is independent. Note its version in your wrapper's docblock. If the wrapper rots, replacing it with forty lines of Alpine plus a pinned signature_pad is a realistic afternoon — which is exactly why you wrapped it.

Language discipline. Never call this "e-signature" in customer-facing material. It is evidence of acknowledgement: a drawn mark, a timestamp, a user ID. It doesn't do signer identity verification, tamper-evident sealing, multi-party routing, or retention guarantees. Write that boundary next to the field; it's cheaper than a compliance conversation after launch.

Debugging checklist when a signature doesn't save, in order: (1) is the field in $data at all — dehydrated(false), disabled state, or a schema branch excluding it; (2) did the JS bundle actually load — check for the canvas's Alpine listeners, not just the element; (3) did a validation error elsewhere wipe the field on round-trip; (4) is the payload empty/white (touched-and-cleared); (5) did the regex/decoder reject an unexpected image format (JPEG data URL from a browser quirk); (6) is disk write failing on permissions or a full volume, and are you swallowing Storage::put()'s boolean return.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core