Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Symfony 7.4 & 8 · PHP 8.2+ · Acrobat Sign REST API v6

E-signatures, wired the Symfony way.

conceptlz/adobe-sign-symfony is a bundle that plugs the Acrobat Sign PHP SDK into Symfony. You get an adobe_sign configuration tree fed by %env()%, autowirable AdobeSign and AdobeSignManager services, OAuth routes so each user can connect their own Acrobat Sign account, encrypted token storage in a cache pool or a Doctrine table, a webhook endpoint that dispatches Symfony events and Messenger messages, and bin/console adobe-sign:verify to check it all works.

composer require conceptlz/adobe-sign-symfony

What the bundle adds

The core package, conceptlz/adobe-sign, does all the Acrobat Sign work: uploading PDFs, building agreements with a signing order, choosing a signature level, discovering which data centre your account lives in, retrying throttled calls and parsing webhooks. It is framework-free on purpose. It speaks only PSR interfaces, so it knows nothing about your service container, your cache pools, your firewall or your Messenger buses.

This bundle connects those two worlds. It contains no Acrobat Sign API logic of its own: every request still goes through the core client. What it adds is the wiring a Symfony developer would otherwise write by hand, following the conventions you already use:

  • Semantic configuration. One adobe_sign tree in config/packages/adobe_sign.yaml, validated by the Config component, with %env()% for secrets and the usual per-environment overrides.
  • Autowirable services. Type-hint Conceptlz\AdobeSign\AdobeSign for the default client, or Conceptlz\AdobeSign\Symfony\AdobeSignManager to choose one. Both come pre-built with your cache pool, your PSR-18 client and a logger on the adobe_sign Monolog channel.
  • OAuth without the plumbing. Two importable routes run the authorization-code flow with a single-use state in the session, store tokens encrypted with libsodium, and dispatch an OAuthConnectedEvent. $manager->forUser($user) then acts with that user's account and refreshes tokens as needed.
  • Token storage you choose. A persistent cache pool by default, a Doctrine DBAL table (picked up by doctrine:migrations:diff when you use the ORM), or your own TokenStore service.
  • A ready-made webhook endpoint. A stateless route that answers Adobe's verification handshake, rejects unknown client ids, drops retried duplicates, dispatches a WebhookReceivedEvent and, if you switch it on, an AdobeSignWebhookMessage on your Messenger bus.
  • Worker-safe. The manager is tagged kernel.reset, so Messenger consumers and FrankenPHP worker mode start every message or request with fresh clients.
  • Console commands. adobe-sign:verify prints who your credentials belong to; adobe-sign:create-token-table creates the token table for projects without the ORM.

A first taste

Here is a contract going out from a controller. The injected client already knows your Integration Key, your cache pool and your project's default signature level, so the code only describes the business process: who signs, in which order, and which webhook tells you about it.

src/Controller/ContractController.php
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantSetInfo, WebhookInfo};
use Conceptlz\AdobeSign\Webhook\EventType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

final class ContractController extends AbstractController
{
    #[Route('/contracts', methods: ['POST'])]
    public function send(Request $request, AdobeSign $adobeSign): JsonResponse
    {
        $pdf = $request->files->get('contract');
        $documentId = $adobeSign->transientDocuments()->upload($pdf, 'msa.pdf', 'application/pdf');

        $agreementId = $adobeSign->agreements()->create(
            AgreementInfo::create('Master Services Agreement')
                ->file(FileInfo::transientDocument($documentId))
                ->participantSet(ParticipantSetInfo::signer('client@example.com')->order(1))
                ->participantSet(ParticipantSetInfo::signer('ceo@example.com')->order(2)),
        );

        $adobeSign->webhooks()->create(
            WebhookInfo::forAgreement(
                "Agreement $agreementId",
                $this->generateUrl('adobe_sign_webhook', [], UrlGeneratorInterface::ABSOLUTE_URL),
                $agreementId,
            )->events(EventType::AGREEMENT_ALL),
        );

        return $this->json(['agreement_id' => $agreementId], 201);
    }
}

When everyone has signed, Adobe calls the bundle's webhook route. The bundle acknowledges Adobe straight away and puts an AdobeSignWebhookMessage on your bus. A worker picks it up and does the slow work outside Adobe's 5-second window:

src/MessageHandler/StoreSignedAgreement.php
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class StoreSignedAgreement
{
    public function __construct(private readonly AdobeSignManager $adobeSign) {}

    public function __invoke(AdobeSignWebhookMessage $message): void
    {
        $event = $message->event();
        if (!$event instanceof AgreementEvent || !$event->isWorkflowCompleted()) {
            return;
        }

        $client = $this->adobeSign->client();
        if (!$client->agreements()->get($event->agreementId())->isSigned()) {
            return; // webhook payloads aren't signed: confirm with the API first
        }

        $pdf = $client->agreements()->combinedDocument($event->agreementId(), attachAuditReport: true);
        file_put_contents("/var/contracts/{$event->agreementId()}.pdf", $pdf->detach());
    }
}

The walkthrough builds the whole loop step by step, with a small storage service, idempotency and tests.

Features

  • adobe_sign config treeIntegration Key or OAuth, signature level, qualified issuers, cache pool, PSR-18 client, retries, token storage and webhooks, all validated with defaults. See Configuration.
  • AutowiringAdobeSign (the default client, not shared) and AdobeSignManager (public, resettable) can be injected into controllers, services, handlers and commands. See Using the client.
  • OAuth connect routesadobe_sign_oauth_redirect and adobe_sign_oauth_callback: single-use state in the session, a signed-in user required, OAuthConnectedEvent on success. See OAuth.
  • Encrypted tokenslibsodium secretbox with a key derived from kernel.secret (or your own key), in a cache pool, a Doctrine DBAL table or your own store. See Token storage.
  • Webhook endpointGET|POST /adobe-sign/webhook, stateless: client-id allow-list, the echo Adobe requires, 72-hour duplicate suppression and a typed WebhookReceivedEvent. See Webhooks.
  • MessengerOpt in and every new notification is also dispatched as an AdobeSignWebhookMessage, a plain-array message any transport can carry. See Async with Messenger.
  • Signature levelsSet SES, AES or QES for the whole project, then check what each signer reached with $manager->signatureShortfalls($id). See Signature levels.
  • TestablePoint http_client at a mock PSR-18 client in the test environment, post fake webhooks with WebTestCase and assert on the in-memory transport. See Testing.

How it fits together

Your application talks to the autowired services. The bundle builds the core client from your configuration and from services Symfony already has, and it owns the HTTP entry points that Adobe and your users call back on. Everything that speaks to Acrobat Sign lives in the core.

Your app controllers, services, handlers adobe-sign-symfony AdobeSign / AdobeSignManager adobe_sign config tree OAuth routes + TokenStore webhook route → event, message cache pool · PSR-18 · logger adobe-sign:verify Core SDK conceptlz/ adobe-sign Acrobat Sign events webhooks, OAuth redirects
The bundle configures the core client, owns the routes Adobe and your users call back on, and turns callbacks into Symfony events and messages. API logic stays in the core.

The core package and its documentation

Installing this bundle pulls in conceptlz/adobe-sign. All three packages (core, Laravel bridge, Symfony bundle) share one version number and are released together; the bundle requires the core at ^0.5 and first shipped in 0.5.0. All builders (AgreementInfo, ParticipantSetInfo, FileInfo, WebhookInfo, Verification), resources (agreements(), transientDocuments(), webhooks(), …), models and exceptions come from the core and are documented in the core SDK documentation. This site covers what is specific to Symfony and shows the core calls in a Symfony setting.

You want to…Read
Install, configure and verify credentialsInstallation, Configuration
Inject the client, choose between clients, act as another userUsing the client
Let each user connect their own Acrobat Sign accountOAuth, Token storage
Receive signing progress in listeners or Messenger handlersWebhooks, Async with Messenger
Learn every builder option, identity checks, downloadsCore: Creating agreements, Identity verification, Downloads
Understand SES, AES and QES in legal termsCore: Signature levels
Use Laravel insteadLaravel bridge documentation

Requirements

  • PHP 8.2 or newer, with the sodium extension (bundled with PHP since 7.2; it encrypts stored OAuth tokens).
  • Symfony 7.4 or 8.x (symfony/* ^7.4 || ^8.0). CI runs Symfony 7.4 on PHP 8.2–8.4 and Symfony 8 on PHP 8.4–8.5.
  • A PSR-18 HTTP client. With symfony/http-client and nyholm/psr7 installed, the framework's own one is used.
  • An Acrobat Sign account. A free developer account is enough to build and test; its agreements are marked as demo documents.
  • Optional: doctrine/dbal for the token table, symfony/messenger for async webhooks, symfony/security-bundle for OAuth. A public HTTPS URL only if you receive webhooks.

Where next