la-souris/document-signer-sdk
Laravel/PHP SDK for document signing workflows—create, send, and track signature requests directly from your app. Wraps the Document Signer API with a clean client, typed DTOs, and framework-ready configuration for quick integration into Laravel projects.
v1.0.0 is a fresh package, so treat the first session as an integration spike rather than a full rollout.
Minimum viable setup
composer require the package (PHP 8.2+ is required — check your CI matrix and any platform.php pin in composer.json first).node -v and a Chromium path on every environment before writing contract code.SignatureProvider implementation in a service provider (or via the package's config if it ships a driver-style config).{[type:signer:name]} placeholder syntax.BrowsershotPdfRenderer, send via send(), store the returned envelope/document identifier on your own model, then poll or wait for the webhook and call downloadSignedDocument() and downloadAudit().Where to look first
SignatureProvider — this is the contract that defines your whole integration surface (send(), downloadSignedDocument(), downloadAudit(), webhook handling).PlaceholderParser and AbstractAnchorPlaceholderReplacer — understand how placeholders become signature anchors before you design templates.BrowsershotPdfRenderer — the header/footer and page-decoration options are where your branding lives.SignedDocumentUnavailableException, which shapes your retry design.First use case
A single-signer NDA or contractor agreement:
render HTML → PDF → send() → persist Document::$id → receive webhook → downloadSignedDocument($document->id) → store on disk → optionally downloadAudit().
Bind the provider behind config, not new
Keep the concrete provider out of controllers and jobs. Resolve SignatureProvider from the container, selected by a config value, so switching from DocuSign to ValidSign is a config/env change rather than a code change. This is the entire point of the abstraction — don't undermine it in your first controller.
$this->app->bind(SignatureProvider::class, fn () =>
match (config('esign.provider')) {
'docusign' => new DocuSignProvider(config('esign.docusign')),
'validsign' => new ValidSignProvider(config('esign.validsign')),
}
);
Persist your own Document record before sending
Because downloadSignedDocument() accepts your Document::$id uniformly across providers, your local documents table is the source of truth. Store your id, the provider's envelope id, status, timestamps, and storage paths. Never rely on the provider dashboard as your state store.
Model the lifecycle as a state machine
draft → rendered → sent → delivered → signed → completed → downloaded. Use a status column plus guarded transitions, and drive transitions from webhooks and from your download jobs. This keeps you sane when a provider fires events out of order or duplicates them.
Put sending and downloading on the queue
Both are slow, network-bound operations. The natural pattern:
SendContractJob — renders via Browsershot, calls send(), persists the envelope id.FetchSignedDocumentJob — calls downloadSignedDocument(), catches SignedDocumentUnavailableException, and releases with backoff.public function handle(SignatureProvider $provider): void
{
try {
$pdf = $provider->downloadSignedDocument($this->document->id);
Storage::disk('contracts')->put($this->document->signedPath(), $pdf);
$this->document->markSigned();
} catch (SignedDocumentUnavailableException $e) {
$this->release(now()->addMinutes(5));
}
}
Webhooks as a thin controller, thick job
Route the provider callback to a controller that verifies the payload and dispatches an event or job. Keep signature verification and payload normalisation inside the provider implementation where possible, so your controller doesn't grow vendor-specific branches. Make handlers idempotent — webhooks retry.
Pair the signed document with the audit certificate
downloadAudit() returns the provider's human-readable completion certificate as PDF. Store it alongside the signed PDF and reference both from your document record. This is exactly the artefact legal/compliance will ask for later, and it's much easier to fetch at completion time than months afterward.
Template management
Keep contract templates as Blade/HTML views or in the database, with the {[type:signer:name]} placeholders in plain text. Version your templates (a template_version column) so you can prove which wording a signer actually saw. Render with the same BrowsershotPdfRenderer in tests as in production so layout regressions are caught.
Testing
Write a FakeSignatureProvider implementing the same interface, returning fixture PDFs. Bind it in TestCase. This lets you test the full job/webhook/state-machine flow without hitting a vendor sandbox, and it validates that you're genuinely decoupled from the provider.
Browsershot is the most fragile dependency
Puppeteer/Chromium issues dominate local-vs-CI-vs-production drift: missing shared libraries in slim Docker images, Chromium sandbox flags in containers, memory limits on large templates, and timeouts on slow first renders. Pin the Chromium path via env, run a render smoke test in CI, and give the job a generous timeout. Consider rendering in a dedicated queue with a higher memory limit.
Anchor-based replacement is only as good as the PDF text layer
Placeholders are located by their text anchor, so anything that mangles extracted text breaks the mapping: custom/subset fonts, ligatures, letter-spacing, non-breaking spaces, line wrapping inside a placeholder, or CSS that splits a placeholder across elements. Keep placeholders on a single line, in a plain font, with normal spacing — and add a test that renders a template and asserts every placeholder resolves. Silent missing anchors are far worse than a loud failure.
SignedDocumentUnavailableException is expected, not exceptional
It fires when a signed document isn't yet available for a finalised envelope — a normal timing race, not a bug. Catch it explicitly before your generic ProviderException handler and treat it as retryable. Because PHP exception order matters, order your catch blocks from most specific to most general.
Don't conflate your Document::$id with the provider's envelope id
The uniform downloadSignedDocument($document->id) signature is a real convenience, but only if you keep two columns: your id (what you pass around) and the provider's id (what the provider's API expects, used internally and for webhook correlation). Storing only one will hurt.
Map the exception hierarchy onto Laravel's retry semantics
ProviderTransientException / ProviderRateLimitException → $this->release() with exponential backoff; respect Retry-After if the provider exposes it.ProviderAuthenticationException → refresh the token/credential and retry once; if it fails again, fail loudly — usually an expired or misconfigured secret.ProviderValidationException → do not retry; it's your payload or template at fault.ProviderNotFoundException / ProviderException → log with context, alert.Webhook security and idempotency
Verify the provider's signature/HMAC before trusting any payload, and dedupe on the provider's event id. Assume at-least-once delivery. Also confirm webhook routes are excluded from CSRF (api middleware group or VerifyCsrfToken::$except), which is the classic first-day 419 error.
Timezones and date formats in the audit PDF
The completion certificate carries provider-rendered dates, often in the provider's timezone. Store your own signed_at timestamp from the webhook payload rather than parsing dates out of the audit PDF.
Storage hygiene
Signed PDFs and audit certificates are sensitive. Use a private disk (not public), generate temporary URLs for authorised viewers, and avoid streaming large PDFs through PHP responses in web requests — sign URLs instead.
Extension points worth knowing
If the built-in anchor replacer doesn't fit your template style, AbstractAnchorPlaceholderReplacer is the seam to subclass. Similarly, BrowsershotPdfRenderer is the place to customise headers/footers and page decoration. Both are preferable to forking the package.
Version pinning
First public release means the interface is not yet battle-tested. Pin to ^1.0 and read the changelog on every minor bump — a SignatureProvider interface change would be a breaking change across every implementation you've written.
How can I help you explore Laravel packages today?