Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Guides

Walkthrough: send & store

Build the complete loop a contract takes through a Symfony app: a form uploads the PDF, a controller sends it in signing order, Adobe calls your webhook when everyone has signed, and a Messenger handler downloads the signed PDF and stores it. Every class is complete and copy-pasteable, and each step explains why it is written that way.

The loop at a glance

Upload form Step 3 ContractController upload · create · webhook Acrobat Sign emails the signers Signers client, then CEO /adobe-sign/webhook verify · dedupe · 200 async transport WebhookMessage StoreSignedAgreement worker · Step 6 Stored PDF status SIGNED POST when complete GET status, signed PDF
The request that sends the contract and the request that receives the webhook never wait for each other. The slow part (downloading and storing) runs in a worker, outside Adobe's 5-second webhook window.

The example is a small contracts app: staff upload a PDF, type the client's email, and the contract goes out for signature, first by the client and then by your CEO. When both have signed, the signed PDF with Adobe's audit report lands in var/contracts/ and the contract shows as signed.

Before you start

  • The bundle installed with an Integration Key that works: php bin/console adobe-sign:verify prints your user. See Installation.
  • Doctrine ORM (composer require symfony/orm-pack) and Messenger (composer require symfony/messenger). The examples use the Doctrine transport; Redis or AMQP work the same way.
  • For trying it locally: a public HTTPS URL that reaches your machine, because Adobe calls your webhook from the internet. See local development with a tunnel.
  • Your webhook's client id. With an Integration Key it is the client id of an Acrobat Sign API application in the same account; Adobe sends it in X-AdobeSign-ClientId. If unsure, log the header once with a temporary listener; see the allow-list.

Step 1: Configuration

Four small files. First the bundle: the key, the webhook allow-list, and Messenger dispatch switched on.

config/packages/adobe_sign.yaml
adobe_sign:
    integration_key: '%env(ADOBE_SIGN_INTEGRATION_KEY)%'
    webhooks:
        client_ids: ['%env(ADOBE_SIGN_CLIENT_ID)%']
        messenger: true

Then the routes. This app only needs the webhook:

config/routes/adobe_sign.yaml
adobe_sign_webhook:
    resource: '@AdobeSignBundle/config/routes/webhook.php'

Messenger: an async transport, a failure transport, and the bundle's message routed to it.

config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'   # e.g. doctrine://default
                retry_strategy:
                    max_retries: 5
                    delay: 10000          # 10 s, then 30 s, 90 s, ...
                    multiplier: 3
            failed: 'doctrine://default?queue_name=failed'
        routing:
            Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage: async

And security: Adobe sends no cookies and can't log in, so the webhook path must not require a user. A dedicated firewall with security switched off, listed before main, is the clearest way:

config/packages/security.yaml
security:
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        adobe_sign_webhook:
            pattern: ^/adobe-sign/webhook$
            security: false
        main:
            lazy: true
            # ... your login configuration

Step 2: The Contract entity

The app's own record of each contract. It stores Adobe's agreement id so the webhook can find the contract again, and a status that only moves forward.

src/Entity/Contract.php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\ContractRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ContractRepository::class)]
class Contract
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 64, unique: true, nullable: true)]
    private ?string $agreementId = null;

    #[ORM\Column(length: 32)]
    private string $status = 'draft';

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $signedPdfPath = null;

    public function __construct(
        #[ORM\Column(length: 255)] private string $title,
        #[ORM\Column(length: 255)] private string $clientEmail,
    ) {
    }

    public function getId(): ?int { return $this->id; }
    public function getTitle(): string { return $this->title; }
    public function getClientEmail(): string { return $this->clientEmail; }
    public function getAgreementId(): ?string { return $this->agreementId; }
    public function getStatus(): string { return $this->status; }

    public function markSent(string $agreementId): void
    {
        $this->agreementId = $agreementId;
        $this->status = 'sent';
    }

    public function markSigned(string $pdfPath): void
    {
        $this->signedPdfPath = $pdfPath;
        $this->status = 'signed';
    }

    public function markClosed(string $adobeStatus): void
    {
        $this->status = strtolower($adobeStatus);   // "cancelled", "expired"
    }

    public function isFinal(): bool
    {
        return in_array($this->status, ['signed', 'cancelled', 'expired'], true);
    }
}
php bin/console make:migration && php bin/console doctrine:migrations:migrate

Step 3: The controller

A minimal form posts the PDF and the client's email:

templates/contract/new.html.twig
<form method="post" enctype="multipart/form-data" action="{{ path('contract_send') }}">
    <input type="text" name="title" required placeholder="Master Services Agreement">
    <input type="email" name="client_email" required placeholder="client@example.com">
    <input type="file" name="contract" accept="application/pdf" required>
    <input type="hidden" name="_token" value="{{ csrf_token('send-contract') }}">
    <button>Send for signature</button>
</form>

The controller does three calls to Acrobat Sign and one database write:

src/Controller/ContractController.php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Contract;
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantSetInfo, WebhookInfo};
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Webhook\EventType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

final class ContractController extends AbstractController
{
    public function __construct(
        private readonly AdobeSignManager $adobeSign,
        private readonly EntityManagerInterface $em,
    ) {
    }

    #[Route('/contracts/new', name: 'contract_new', methods: ['GET'])]
    public function new(): Response
    {
        return $this->render('contract/new.html.twig');
    }

    #[Route('/contracts', name: 'contract_send', methods: ['POST'])]
    public function send(Request $request): Response
    {
        if (!$this->isCsrfTokenValid('send-contract', $request->request->getString('_token'))) {
            throw $this->createAccessDeniedException();
        }
        $pdf = $request->files->get('contract');
        if (!$pdf instanceof UploadedFile || $pdf->getMimeType() !== 'application/pdf') {
            throw new BadRequestHttpException('Upload a PDF.');
        }

        $contract = new Contract($request->request->getString('title'), $request->request->getString('client_email'));
        $client = $this->adobeSign->client();

        // 1. Upload the PDF as a transient document (valid for 7 days).
        $documentId = $client->transientDocuments()->upload($pdf, $pdf->getClientOriginalName(), 'application/pdf');

        // 2. Create the agreement: the client signs first, then the CEO.
        $agreementId = $client->agreements()->create(
            AgreementInfo::create($contract->getTitle())
                ->file(FileInfo::transientDocument($documentId))
                ->participantSet(ParticipantSetInfo::signer($contract->getClientEmail())->order(1))
                ->participantSet(ParticipantSetInfo::signer('ceo@example.com')->order(2))
                ->message('Please review and sign.'),
        );

        // 3. Ask Adobe to call our webhook about this agreement.
        $client->webhooks()->create(
            WebhookInfo::forAgreement(
                "Contract $agreementId",
                $this->generateUrl('adobe_sign_webhook', [], UrlGeneratorInterface::ABSOLUTE_URL),
                $agreementId,
            )->events(
                EventType::AGREEMENT_WORKFLOW_COMPLETED,
                EventType::AGREEMENT_REJECTED,
                EventType::AGREEMENT_RECALLED,
                EventType::AGREEMENT_EXPIRED,
            ),
        );

        $contract->markSent($agreementId);
        $this->em->persist($contract);
        $this->em->flush();

        $this->addFlash('success', 'Sent for signature.');

        return $this->redirectToRoute('contract_new');
    }
}

What each part of send() does

  • It injects the manager, not the client. With an Integration Key either works; the manager keeps the controller unchanged if you later switch some senders to OAuth (forUser($this->getUser())).
  • It names the upload. The UploadedFile's path is PHP's temporary file; the second argument is the name signers see in their email.
  • The order is explicit. order(1) and order(2) make it a sequence: the CEO is only emailed after the client signs. Two sets with the same order would sign in parallel.
  • The webhook is per agreement. Adobe recommends it: if deliveries to your URL fail for a long time, Adobe disables the webhook, and that then affects one agreement rather than all. Creating it makes Adobe call your route immediately with a verification GET, so the route must be reachable at this point.
  • It subscribes only to final events. This app only acts when signing ends. EventType::AGREEMENT_ALL would also deliver views, reminders and each signature, which is fine but more traffic.
  • It saves after Adobe accepts. If any call throws, nothing is persisted and the exception reaches the user (map it with an exception listener). If the webhook registration fails after the agreement was created, the agreement still exists at Adobe; in production, persist the contract right after step 2 so a retry doesn't send twice.

Step 4: The webhook route (already done)

You wrote no code for this step: the bundle's WebhookController sits behind /adobe-sign/webhook. On the verification GET it checks the client id and echoes it. On each notification POST it checks the client id, parses the body, drops it if the webhookNotificationId was already seen within dedupe_ttl, dispatches a WebhookReceivedEvent and, because you enabled Messenger, an AdobeSignWebhookMessage. Then it answers 200 with the echo. The whole thing takes milliseconds, well inside Adobe's 5-second limit. Webhooks covers every branch.

Step 5: Route the message to the worker

Already done in Step 1's messenger.yaml. It matters more than it looks: without the routing line, Messenger handles the message synchronously, inside the webhook request, and the download would run within Adobe's 5-second window. Check the routing with:

php bin/console debug:messenger

Step 6: The Messenger handler

The handler does the slow, fallible part, in a worker, with its own retry budget.

src/MessageHandler/StoreSignedAgreement.php
<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Contract\ContractStorage;
use App\Repository\ContractRepository;
use Conceptlz\AdobeSign\Exception\RateLimitException;
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;

#[AsMessageHandler]
final class StoreSignedAgreement
{
    public function __construct(
        private readonly AdobeSignManager $adobeSign,
        private readonly ContractRepository $contracts,
        private readonly ContractStorage $storage,
        private readonly EntityManagerInterface $em,
        private readonly MessageBusInterface $bus,
    ) {
    }

    public function __invoke(AdobeSignWebhookMessage $message): void
    {
        $event = $message->event();
        $agreementId = $event instanceof AgreementEvent ? $event->agreementId() : null;
        if ($agreementId === null) {
            return;                                  // not about an agreement
        }

        $contract = $this->contracts->findOneBy(['agreementId' => $agreementId]);
        if ($contract === null || $contract->isFinal()) {
            return;                                  // not ours, or already handled
        }

        $client = $this->adobeSign->client();
        try {
            // Webhook payloads aren't signed: ask the API for the truth before acting.
            $agreement = $client->agreements()->get($agreementId);

            if ($agreement->isSigned()) {
                $pdf = $client->agreements()->combinedDocument($agreementId, attachAuditReport: true);
                $contract->markSigned($this->storage->save($agreementId, $pdf));
            } elseif (in_array($agreement->status, ['CANCELLED', 'EXPIRED'], true)) {
                $contract->markClosed($agreement->status);
            } else {
                return;                              // still in progress
            }
        } catch (RateLimitException $e) {
            // Adobe asked for a long pause: come back later instead of blocking the worker.
            $this->bus->dispatch($message, [new DelayStamp(($e->retryAfter ?? 60) * 1000)]);

            return;
        }

        $this->em->flush();
    }
}

Why the handler is written this way

  • It re-checks the status. Adobe doesn't sign webhook payloads. The client-id allow-list stops casual forgeries, but the safe pattern is to treat the webhook as a hint and ask the API. Only a real SIGNED status leads to a download.
  • It is idempotent. The bundle drops duplicate deliveries, but Messenger can still run a handler twice (a retry after a crash, a replay from the failure transport). isFinal() turns the second run into a no-op.
  • It asks for the audit report. attachAuditReport: true appends Adobe's audit trail (who signed, when, from which IP, with which verification) to the PDF. Keep it with the document: it is what you show when a signature is challenged.
  • It streams. combinedDocument() returns a PSR-7 stream that the storage service copies to disk in chunks.
  • It cooperates with throttling. Short Retry-After waits are retried inside the SDK (up to retries.max_delay_seconds). A longer one surfaces as RateLimitException, and the handler re-queues the message with a DelayStamp rather than burning a Messenger retry. Every other exception goes to Messenger's retry strategy and finally to the failed transport.
  • It injects the manager. The worker is long-running; client() per message picks up the fresh client after each kernel.reset. With OAuth there is no signed-in user in a worker: store the sender's key on the contract and call forUser($contract->getSenderKey()).

Step 7: The storage service

src/Contract/ContractStorage.php
<?php

declare(strict_types=1);

namespace App\Contract;

use Psr\Http\Message\StreamInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;

final class ContractStorage
{
    public function __construct(
        #[Autowire('%kernel.project_dir%/var/contracts')] private readonly string $directory,
        private readonly Filesystem $filesystem = new Filesystem(),
    ) {
    }

    /** Copies the stream to disk and returns the path. */
    public function save(string $agreementId, StreamInterface $pdf): string
    {
        $path = sprintf('%s/%s/%s.pdf', $this->directory, date('Y'), $agreementId);
        $this->filesystem->mkdir(\dirname($path));

        $in = $pdf->detach() ?? throw new \RuntimeException('Download stream is not readable.');
        $out = fopen($path.'.part', 'wb') ?: throw new \RuntimeException("Cannot write $path.");
        stream_copy_to_stream($in, $out);
        fclose($in);
        fclose($out);

        // Rename last: a half-written file never looks like a finished contract.
        $this->filesystem->rename($path.'.part', $path, true);

        return $path;
    }
}

On several servers, write to shared storage instead. With Flysystem (league/flysystem-bundle) the body of save() becomes $this->storage->writeStream($key, $pdf->detach()), and S3, Azure or GCS work the same way.

Step 8: Try it

  1. Start the app and a tunnel

    Run symfony server:start, then expose it over HTTPS with a tunnel and open the app through the tunnel's URL, so generateUrl(..., ABSOLUTE_URL) produces a URL Adobe can reach. If your tunnel sets X-Forwarded-* headers, configure framework.trusted_proxies so the URL is https://.

  2. Start a worker
    php bin/console messenger:consume async -vv

    -vv prints every message as it is received and handled.

  3. Send a contract

    Open /contracts/new, use addresses you control (they receive real emails), and submit. Adobe calls the webhook once to verify it; the adobe_sign log stays quiet if the client id matched.

  4. Sign as both signers

    Sign from the first email, then the second. After the last signature Adobe posts AGREEMENT_WORKFLOW_COMPLETED; the worker prints the handled message, and the PDF appears in var/contracts/.

Step 9: Test it without Adobe

Two tests cover the loop. The first drives the real webhook route and checks that a message was queued; the second runs the handler against a mock PSR-18 client. Configure the mock as shown in Testing, and make the async transport in-memory:// in the test environment.

tests/Functional/ContractWebhookTest.php
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;

final class ContractWebhookTest extends WebTestCase
{
    public function test_completed_agreement_is_queued_once(): void
    {
        $client = static::createClient();
        $client->disableReboot();
        $payload = (string) file_get_contents(__DIR__.'/../Fixtures/workflow_completed.json');

        $client->request('POST', '/adobe-sign/webhook', server: [
            'HTTP_X_ADOBESIGN_CLIENTID' => 'test-client-id',
            'CONTENT_TYPE' => 'application/json',
        ], content: $payload);

        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('X-AdobeSign-ClientId', 'test-client-id');

        $transport = static::getContainer()->get('messenger.transport.async');
        self::assertInstanceOf(InMemoryTransport::class, $transport);
        self::assertCount(1, $transport->getSent());
        self::assertInstanceOf(AdobeSignWebhookMessage::class, $transport->getSent()[0]->getMessage());
    }
}

The handler test queues Adobe's answers and checks the result. The full pattern, including the fixture and the mock setup, is in testing your own handlers.

Hardening for production

  • Shared cache. Duplicate detection lives in cache_pool. With several web servers, point it at Redis or another shared pool, or a retry landing on a second server is processed again. See cache configuration.
  • Workers under supervision. Run messenger:consume async --time-limit=3600 --memory-limit=256M under systemd or Supervisor, and messenger:stop-workers on deploy. See running workers.
  • Failure handling. Watch the failed transport (messenger:failed:show) and alert on it. After 72 hours of failed deliveries Adobe disables a webhook, so a scheduled command that re-checks contracts still marked "sent" after a few days is a cheap safety net.
  • Timeouts. Large agreements take time to download. Make sure framework.http_client's max_duration fits, and that the worker's time limit is longer.
  • Send once. Persist the contract with its agreement id before registering the webhook, so a retry of a failed request doesn't create a second agreement. Use externalId() on the builder to find agreements Adobe created but you didn't record.
  • Signature level. If the project requires AES or QES, check it in the handler before marking the contract signed: $this->adobeSign->signatureShortfalls($agreementId). See checking after signing.

Common questions

webhooks()->create() fails while creating the agreement works. Why?

Adobe calls your URL with a GET before creating the webhook. It must be public HTTPS, not behind a firewall that requires login, and the client id Adobe sends must be in your allow-list. Locally you need a tunnel. See Troubleshooting.

Everyone signed, but no PDF appeared. Where do I look?

In order: is a worker consuming async? Is the message routed (debug:messenger)? Anything in messenger:failed:show? Any "Rejected Acrobat Sign webhook" warning in the adobe_sign log? Is the webhook still active in Acrobat Sign? See Troubleshooting.

Can I use one account-wide webhook instead of one per agreement?

Yes: create it once with WebhookInfo::forAccount('All agreements', $url) (an account-admin task) and drop step 3 of the controller. The handler already ignores agreements it doesn't know. Adobe recommends per-agreement webhooks because a disabled webhook then affects one agreement.

How does this change if users send from their own accounts via OAuth?

In the controller use $this->adobeSign->forUser($this->getUser()) and store $this->adobeSign->userKey($this->getUser()) on the contract. In the handler, call forUser() with that stored key. The webhook route doesn't change. See OAuth.

Can I store the audit report as a separate file?

Yes. Call combinedDocument($id) without attachAuditReport for the signed PDF and auditTrail($id) for the report, and save each stream separately. The core's downloads page lists the other formats.