gheb/docusign-bundle
Symfony bundle for DocuSign eSignature integration, providing configuration, API client services, and helpers to send envelopes, manage templates and recipients, and build document signing workflows in PHP applications.
This bundle is a thin Symfony DI wrapper around the official docusign/esign-client PHP SDK. It saves you from wiring ApiClient/EnvelopesApi/AuthenticationApi by hand and from re-implementing the JWT Grant flow; it does not model envelopes, recipients, or webhooks for you. Budget for learning the DocuSign object model (EnvelopeDefinition, Document, Signer, Tabs) — that's where 90% of the work lives.
Install
composer require gheb/docusign-bundle
No Flex recipe, so register the bundle manually in config/bundles.php:
Gheb\DocuSignBundle\GhebDocuSignBundle::class => ['all' => true],
As of 5.6.5 the bundle allows psr/log:^3.0, so it installs cleanly alongside Laravel 10/11 and Symfony 6.3+ (which require psr/log 3). Before 5.6.5 that constraint was the most common reason the bundle refused to install on a modern app — if you had it on a "won't install" list, re-check.
Create the credentials on the DocuSign side (this is the part people underestimate):
https://account-d.docusign.com/oauth/auth?response_type=code&scope=signature%20impersonation&client_id={INTEGRATION_KEY}&redirect_uri={YOUR_URI}
Consent is per (integration key, user) pair and only needs doing once per environment.Configure — dump the reference rather than trusting the README, since the extension alias is derived from the bundle name:
bin/console config:dump-reference gheb_docu_sign
bin/console debug:container --tag=container.service_subscriber | grep -i docusign
Typical shape:
# config/packages/gheb_docu_sign.yaml
gheb_docu_sign:
integration_key: '%env(DOCUSIGN_INTEGRATION_KEY)%'
user_id: '%env(DOCUSIGN_USER_ID)%' # the impersonated GUID, not an email
account_id: '%env(DOCUSIGN_ACCOUNT_ID)%'
private_key: '%env(DOCUSIGN_PRIVATE_KEY)%' # or a path, depending on the option
base_path: 'https://demo.docusign.net/restapi' # production: https://www.docusign.net/restapi
First use case — embedded signing. The highest-value, lowest-risk flow to start with: create an envelope, get a recipient view URL, redirect the user to DocuSign, then handle the return.
$definition = (new EnvelopeDefinition())
->setEmailSubject('Please sign the NDA')
->setStatus('sent')
->setDocuments([$document])
->setRecipients(new Recipients(['signers' => [$signer]]));
$summary = $envelopesApi->createEnvelope($accountId, $definition);
$view = $envelopesApi->createRecipientView($accountId, $summary->getEnvelopeId(), $viewRequest);
return $this->redirect($view->getUrl());
Where to look first: the bundle's Resources/config/services.yaml (what's actually registered and with what arguments), then the DocuSign esign-client README and the "eSignature REST API" reference. The bundle source is small enough to read end-to-end in ten minutes — do that before fighting with autowiring.
The bundle's real value is that ApiClient is pre-authenticated at container build time with JWT. Autowire EnvelopesApi, AccountsApi, and FoldersApi and keep your own domain service around them:
final class SignatureRequestSender
{
public function __construct(
private EnvelopesApi $envelopesApi,
private string $accountId,
) {}
}
Keep the DocuSign DTOs at the boundary. Map your Contract entity → EnvelopeDefinition → back to an envelopeId string stored on the entity. Nothing else in your app should know about SignHere tabs.
| Flow | Status on create | Use when |
|---|---|---|
| Remote (email) | sent |
Signer is external, no login, you just wait for the webhook. Simplest. |
| Embedded (recipient view) | sent, then createRecipientView |
Signer is already logged into your app; you control the UX and the return URL. |
Embedded signing requires a returnUrl that DocuSign will redirect to — it must be reachable and, for anything beyond demo, must match a domain you've configured/verified. recipientViewRequest needs authenticationMethod: 'none' or 'email', the signer's email/userName, and a clientUserId that matches the one you set on the Signer in the envelope. Mismatched clientUserId is the single most common embedded-signing failure — it silently falls back to an invalid-recipient error.
Don't poll — or if you must, poll on a low frequency with a sentAt guard. Configure a Connect webhook in the DocuSign admin pointing at your controller:
#[Route('/webhook/docusign', methods: ['POST'])]
public function connect(Request $request, EnvelopeRepository $envelopes): Response
{
$payload = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
foreach ($payload['data']['envelopeSummary'] ?? [] as $summary) {
$envelope = $envelopes->findOneByDocusignId($summary['envelopeId']);
if (!$envelope) { continue; } // unknown envelope: ignore, don't 500
if ($envelope->getStatus() === $summary['status']) { continue; } // idempotent
$envelope->transitionTo($summary['status']);
}
return new Response('', 204); // always 204/200, or DocuSign retries
}
Include an HMAC check (DocuSign Connect supports an HMAC key) and, better, re-fetch the envelope from the API before mutating your state — the webhook body is attacker-controllable if the endpoint is public and you haven't verified it.
created → sent → delivered → completed | declined | voided | correct. Map it to your own enum and only allow forward transitions. Handle declined and voided explicitly — they're the ones that leave dangling "pending signature" banners in your UI.
On completed, download and store it yourself; don't rely on DocuSign's retention.
$tmp = $envelopesApi->getDocument($accountId, 'combined', $envelopeId);
$filesystem->write($path, file_get_contents($tmp->getPathname()));
combined gives you the merged PDF with the certificate of completion; use the per-document documentId if you need individual files.
Mock your own SignatureRequestSender interface, not the DocuSign SDK — the SDK classes are generated and painful to mock. For an end-to-end smoke test, point base_path at demo and use a sandbox account; assert you can create and void an envelope rather than asserting on exact payloads.
The package moves slowly, but it is not abandoned. 5.6.5 (a CS fix plus psr/log:^3.0 support) landed after a long quiet period, so the project is still receiving maintenance PRs — just at a low cadence and without feature work. Practical consequences: pin an explicit version ("gheb/docusign-bundle": "5.6.5") rather than a floating constraint, and before adopting run composer why docusign/esign-client to check whether the bundle's docusign/esign-client constraint collides with anything else in your tree. That constraint is the remaining install-time risk; 5.6.5 solved the psr/log side of the problem, not the SDK side. If it does collide, the bundle is small enough that copy-pasting its service definitions into your own services.yaml (or writing a 40-line factory) is often the cheaper path than fighting the constraint.
Private key handling. PEM keys break YAML and env parsing. Two safe options: a path to a file outside the repo (private_key: '%kernel.project_dir%/config/docusign/private.pem') or base64 in an env var decoded in a small factory. If keys were pasted into .env with literal \n, they will fail with a cryptic OpenSSL error — check for real newlines first.
Token lifetime. JWT access tokens expire in one hour. If the bundle authenticates once at container bootstrap you'll be fine for a request-per-process model (PHP-FPM), but long-running workers (Messenger consumers) will keep a stale ApiClient. Re-authenticate inside the consumer, or build a fresh client per message.
Demo vs production base paths are different hosts, not a flag. account-d.docusign.com / demo.docusign.net vs account.docusign.com / www.docusign.net. Mixing them produces 401s that look like auth bugs. Also, demo data does not migrate — envelopes you created in sandbox are gone.
userId vs accountId. userId is the GUID of the impersonated user (find it in DocuSign admin → Users, or via GET /oauth/userinfo), not their email. Wrong GUID → consent_required, which means consent wasn't granted for that exact user, not that your key is bad.
Error handling. The SDK throws DocuSign\eSign\ApiException. Always surface $e->getResponseBody() — it contains errorCode and message from DocuSign (RECIPIENT_NOT_IN_ORDER, ENVELOPE_CANNOT_BE_VOIDED, INVALID_USER_ID). Log the X-DocuSign-TraceToken response header; DocuSign support will ask for it.
Non-idempotent creates. Double-clicking "send for signature" creates two envelopes. Put a unique constraint on your side or generate your own envelope key (DocuSign supports EnvelopeDefinition custom fields) and check before creating.
Rate limits are real. Demo accounts are throttled hard (~1,000 calls/hour per integration key historically). A getEnvelope inside a loop over 500 contracts will fail. Batch, cache, or lean on the webhook.
Document tabs need anchors or explicit coordinates. SignHere with no anchorString and no xPosition/yPosition is silently dropped — the envelope goes out with nothing to sign. Use anchorString with anchorIgnoreIfNotPresent, or supply absolute coordinates against a fixed-size PDF.
Recipient ordering. Set routingOrder on Signers; without it, DocuSign signs in parallel and the order in your array is irrelevant.
Extension points. The bundle has no event system. To add cross-cutting behaviour (logging, metrics, retry) either decorate the SDK API classes via a compiler pass, or — cleaner — wrap them in your own service and never inject the SDK directly outside that layer. This also makes the eventual migration off the bundle a one-file change.
Debugging autowiring. If EnvelopesApi won't inject, run bin/console debug:autowiring docusign. The alias depends on the service IDs the bundle registered, and those can drift between versions.
Upgrading within 5.6.x. 5.6.5 contains no behavioural or API changes — the two PRs are a code-style fix and a dependency constraint widening. A patch bump like this is a safe, no-regression update and worth taking purely to unblock psr/log:^3.0 on modern Laravel/Symfony versions. Still run your existing envelope-creation smoke test afterwards, since the composer resolution it enables can pull in transitive dependency updates.
How can I help you explore Laravel packages today?