Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Guides

Using the client

How Symfony code gets hold of an Acrobat Sign client, which of the two services to inject where, what every method on the manager does, and how the core builders look inside controllers, services and workers. By the end you can send, track and download agreements and turn SDK errors into sensible HTTP responses.

Two services, two jobs

The bundle registers two autowirable services. Both are public, so you can also fetch them from the container in tests.

Type-hintWhat you getInject it when…
Conceptlz\AdobeSign\AdobeSignThe default client: the Integration Key, or with auth: oauth the signed-in user's connection. Built by AdobeSignManager::client(); the service is not shared, so it is resolved each time something asks for it.A controller or request-scoped service only ever sends as "the app" (or as the current user).
Conceptlz\AdobeSign\Symfony\AdobeSignManagerThe factory behind it: client(), forUser(), OAuth helpers, the token store, signature-level helpers. Tagged kernel.reset.You choose between clients, run in a worker or console command, or need OAuth or signature-level helpers.

If in doubt, inject the manager and call client() where you need it. It costs nothing (the client is cached inside the manager) and it behaves correctly in every context, including long-running workers.

Injecting the client

Into a controller action, as an argument:

src/Controller/AgreementController.php
use Conceptlz\AdobeSign\AdobeSign;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class AgreementController extends AbstractController
{
    #[Route('/agreements/{id}/status', methods: ['GET'])]
    public function status(string $id, AdobeSign $adobeSign): JsonResponse
    {
        return $this->json(['status' => $adobeSign->agreements()->status($id)]);
    }
}

Or into a service constructor:

src/Contract/ContractSender.php
final class ContractSender
{
    public function __construct(private readonly AdobeSign $adobeSign) {}
}

With auth: oauth, the client depends on who is signed in

The AdobeSign service is created by calling client() at the moment it is injected. With auth: oauth that means "the user signed in right now". A shared service built before authentication (an event subscriber, a Twig extension), or one used in a worker, would get a MisconfiguredException instead. In OAuth mode, inject AdobeSignManager and call client() or forUser() inside the method.

Injecting the manager

src/Contract/ContractSender.php
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Symfony\Component\Security\Core\User\UserInterface;

final class ContractSender
{
    public function __construct(private readonly AdobeSignManager $adobeSign) {}

    public function send(Contract $contract, ?UserInterface $sender = null): string
    {
        // A connected user sends from their own account; otherwise the app's key.
        $client = $sender !== null && $this->adobeSign->isConnected($sender)
            ? $this->adobeSign->forUser($sender)
            : $this->adobeSign->client();

        return $client->agreements()->create($contract->toAgreementInfo());
    }
}

The manager's methods

AdobeSignManager is a small class; these are all of its public methods. Everything else you call is the core client.

client(): AdobeSign

The default client. With auth: integration_key, a client using integration_key, built once and cached until reset(). With auth: oauth, forUser() for the user in security.token_storage.

Throws MisconfiguredException when the key is empty, when auth is oauth and nobody is signed in, or when auth or signature_level has an unknown value.

forUser(UserInterface|string|int $user): AdobeSign

A client acting with the OAuth tokens stored for this user. A UserInterface goes through the configured UserKeyResolver; a string or int is used as the key directly, which is what you pass from a worker. Tokens refresh automatically shortly before they expire, and the refreshed pair is written back to the store. Clients are cached per key until reset().

A user who never connected doesn't fail here but on the first API call, with OAuthException (NO_TOKEN). Check isConnected() first. Throws MisconfiguredException when the OAuth client id or secret is missing.

isConnected(UserInterface|string|int $user): bool

Whether a token is stored under the user's key. It doesn't call Adobe, so a stored token may still have been revoked by the user in Acrobat Sign; that shows up as an OAuthException on the next call.

oauth(): OAuthClient

The core Conceptlz\AdobeSign\Auth\OAuthClient for the authorization-code flow, configured with your client id, secret and redirect URI (default: the absolute URL of adobe_sign_oauth_callback). The bundle's routes use it; you need it directly for revoke() or a custom connect flow. Throws MisconfiguredException when id or secret is missing, or when no redirect URI can be built.

tokens(): TokenStore

The configured Conceptlz\AdobeSign\Auth\TokenStore: CacheTokenStore, DoctrineDbalTokenStore or your own service. The interface has get() and put(); both shipped stores add forget(). See Token storage.

webhookHandler(): WebhookHandler

A core WebhookHandler allow-listing webhooks.client_ids, or oauth.client_id when that list is empty. The webhook route uses it; you only need it for a custom endpoint. Throws MisconfiguredException when neither is set.

userKey(UserInterface|string|int $user): string

The token-store key for a user: the resolver's answer for a UserInterface, the value itself (as a string) otherwise. Store it with your own records (for example on the contract) so a worker can later call forUser($key) without loading the user.

signatureLevel(): SignatureLevel

The configured signature_level as the core enum Conceptlz\AdobeSign\Enum\SignatureLevel (SES, AES, QES). Throws MisconfiguredException for any other value.

qualifiedIssuers(): list<string>

The configured qualified_issuers.

signatureShortfalls(string $agreementId, ?SignatureLevel $required = null): list<string>GET /agreements/{agreementId} + members + events

Fetches the agreement's signature evidence with the default client and lists, in plain English, every signer who fell short of $required (default: the configured level), using qualified_issuers for QES. Empty means everyone made it. See checking after signing.

reset(): void

Drops the cached default client, the per-user clients and the OAuth client. Symfony calls it through kernel.reset; you rarely call it yourself. See long-running workers.

Core builders in a Symfony app

The bundle adds no API methods. You build requests with the core's fluent builders, which validate locally and throw Conceptlz\AdobeSign\Exception\InvalidArgumentException before anything is sent. The core agreements guide lists every option; here is how the common ones look in Symfony code.

Upload a file from a request

transientDocuments()->upload() accepts a path, an SplFileInfo, a stream resource or a PSR-7 stream. Symfony's UploadedFile is an SplFileInfo, so you can pass it directly, but its path is PHP's temporary file (/tmp/phpA1b2C3). Pass the name Adobe should show as the second argument; signers see it in emails and in the signing page.

src/Controller/UploadController.php
use Conceptlz\AdobeSign\AdobeSign;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
use Symfony\Component\Validator\Constraints as Assert;

#[Route('/documents', methods: ['POST'])]
public function upload(
    AdobeSign $adobeSign,
    #[MapUploadedFile([new Assert\File(maxSize: '20M', mimeTypes: ['application/pdf'])])]
    UploadedFile $contract,
): JsonResponse {
    $documentId = $adobeSign->transientDocuments()->upload(
        $contract,
        $contract->getClientOriginalName(),   // the name signers see; max 255 characters
        'application/pdf',
    );

    // A transient document lives for 7 days: create the agreement soon.
    return $this->json(['document_id' => $documentId], 201);
}

The upload is streamed through a temporary file, so a large PDF is not held in memory twice. To upload something you generated (a Twig-rendered PDF, a file from Flysystem), pass an open stream and a name: upload($stream, 'offer.pdf', 'application/pdf').

Create and send an agreement

src/Contract/ContractSender.php
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantInfo, ParticipantSetInfo, Verification};
use Conceptlz\AdobeSign\Enum\ReminderFrequency;

$agreementId = $client->agreements()->create(
    AgreementInfo::create('Employment contract: Jane Doe')
        ->file(FileInfo::transientDocument($documentId))
        ->participantSet(
            ParticipantSetInfo::signer(
                ParticipantInfo::email('jane@example.com', 'Jane Doe')->verifyWith(Verification::emailOtp()),
            )->order(1),
        )
        ->participantSet(ParticipantSetInfo::signer('hr-director@example.com')->order(2))
        ->cc('payroll@example.com')
        ->message('Please review and sign your contract.')
        ->externalId('contract-'.$contract->getId())
        ->reminderFrequency(ReminderFrequency::DAILY_UNTIL_SIGNED),
);

externalId() is worth setting: it ties Adobe's agreement to your record, and agreements()->all(externalId: ...) can find it again if you ever lose the id. The project's signature_level is applied to every signer set that doesn't set its own.

Track progress

$agreement = $client->agreements()->get($agreementId);
$agreement->status;                 // "OUT_FOR_SIGNATURE", "SIGNED", "CANCELLED", ...
$agreement->isSigned();             // status is SIGNED

$client->agreements()->members($agreementId, includeNextParticipantSet: true);   // whose turn it is
$client->agreements()->signingUrlFor($agreementId, 'jane@example.com');           // embed or redirect

Polling works, but webhooks are the better tool: Adobe tells you when something changes. See Webhooks. Right after creation the agreement is briefly in DOCUMENTS_NOT_YET_PROCESSED and some calls answer AGREEMENT_NOT_EXPOSED; retry after a few seconds.

Stream the signed PDF to the browser

Binary endpoints return a PSR-7 stream. Copy it into a StreamedResponse in chunks rather than reading it into a string:

src/Controller/AgreementController.php
use Symfony\Component\HttpFoundation\{HeaderUtils, StreamedResponse};

#[Route('/agreements/{id}/pdf', methods: ['GET'])]
public function pdf(string $id, AdobeSign $adobeSign): StreamedResponse
{
    $this->denyAccessUnlessGranted('VIEW_AGREEMENT', $id);   // your own voter
    $stream = $adobeSign->agreements()->combinedDocument($id, attachAuditReport: true);

    return new StreamedResponse(static function () use ($stream): void {
        while (!$stream->eof()) {
            echo $stream->read(65536);
            flush();
        }
    }, 200, [
        'Content-Type' => 'application/pdf',
        'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, "agreement-$id.pdf"),
    ]);
}

For storage rather than the browser, see the walkthrough's storage step.

Turn SDK exceptions into HTTP responses

Every exception the SDK and the bundle throw implements Conceptlz\AdobeSign\Exception\AdobeSignException. API errors are ApiException subclasses carrying $statusCode and Adobe's $errorCode. A kernel.exception listener can map them once for the whole app:

src/EventListener/AdobeSignExceptionListener.php
use Conceptlz\AdobeSign\Exception\{AccountCapabilityException, AdobeSignException, InvalidArgumentException,
    NotFoundException, OAuthException, RateLimitException, TransportException, ValidationException};
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::EXCEPTION)]
final class AdobeSignExceptionListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        $e = $event->getThrowable();
        if (!$e instanceof AdobeSignException) {
            return;
        }

        [$status, $message] = match (true) {
            $e instanceof OAuthException => [409, 'Please reconnect your Acrobat Sign account.'],
            $e instanceof AccountCapabilityException => [422, 'Your Acrobat Sign plan or settings do not allow this.'],
            $e instanceof ValidationException, $e instanceof InvalidArgumentException => [422, $e->getMessage()],
            $e instanceof NotFoundException => [404, 'Agreement not found.'],
            $e instanceof RateLimitException => [503, 'Acrobat Sign is busy. Try again shortly.'],
            $e instanceof TransportException => [502, 'Acrobat Sign could not be reached.'],
            default => [500, 'Acrobat Sign returned an error.'],
        };

        $response = new JsonResponse(['error' => $message], $status);
        if ($e instanceof RateLimitException && $e->retryAfter !== null) {
            $response->headers->set('Retry-After', (string) $e->retryAfter);
        }
        $event->setResponse($response);
    }
}

Messages from ValidationException come from Adobe and are safe to show to staff, less so to end users; adapt to your audience. The core's errors page lists every class and the codes that map to it.

Acting as another user: asUser()

With an Integration Key that belongs to an account administrator, you can send as any user in the same account. The core's asUser() returns a new client that adds the x-api-user header; the original client is unchanged.

$asSales = $manager->client()->asUser('sales@example.com');          // "email:" is added for you
$asSales->agreements()->create($agreement);                          // sent in that user's name

$manager->client()->asUser('userid:CBJCHBCAABAA...')->agreements()->all();

This is different from OAuth: asUser() stays inside your Acrobat Sign account and needs an admin-level key; OAuth acts in the user's own account with their consent. Use asUser() when one company sends on behalf of its staff, OAuth when customers bring their own accounts.

Long-running workers and kernel.reset

In classic PHP-FPM, the container lives for one request. Messenger consumers and FrankenPHP's worker mode keep it alive for many requests or messages. The manager caches clients (and the OAuth client with its redirect URI), which would otherwise outlive the request they were built for. So it implements ResetInterface and is tagged kernel.reset:

  • Messenger resets tagged services after each handled message (unless you run messenger:consume --no-reset).
  • FrankenPHP worker mode and other runtimes reset them between requests through the kernel's services resetter.

The reset clears the manager's cache; it can't reach a client you stored. A shared service that received AdobeSign in its constructor keeps that one instance for the life of the worker. With an Integration Key that client still works, but it carries state such as remembered ETags from long ago. The robust pattern for anything that runs in a worker:

Prefer this in handlers, commands and other shared services
public function __construct(private readonly AdobeSignManager $adobeSign) {}

public function __invoke(SomeMessage $message): void
{
    $client = $this->adobeSign->client();     // fresh after every kernel.reset
    // ...
}

The data-centre lookup is not affected by resets: it lives in cache_pool, so a fresh client doesn't call GET /baseUris again.

Logging

The manager and the webhook controller are tagged with the Monolog channel adobe_sign. With MonologBundle installed, give the channel its own file and level:

config/packages/monolog.yaml
monolog:
    channels: ['adobe_sign']
    handlers:
        adobe_sign:
            type: rotating_file
            path: '%kernel.logs_dir%/adobe_sign.log'
            level: info              # "debug" also logs every request line
            channels: ['adobe_sign']
        main:
            # ...
            channels: ['!adobe_sign']
LevelMessageFrom
debugAdobe Sign requestCore transport, one line per request (method, path, status)
warningAdobe Sign throttled request, retryingCore retry middleware on 429/503
errorAdobe Sign request failedCore transport, when a request could not be sent at all (network failure, timeout)
warningRejected Acrobat Sign webhookWebhook controller: missing or unknown client id, with the reason and client IP
errorUnreadable Acrobat Sign webhookWebhook controller: body that isn't a JSON event

The SDK never logs headers or bodies, so keys, tokens and document contents stay out of your logs. Without MonologBundle, the messages go to Symfony's default logger service.

Common questions

The AdobeSign service is "not shared". Do I get a new client every time?

No. "Not shared" means the container calls $manager->client() each time; the manager returns its cached instance until kernel.reset. The point is that with auth: oauth, the answer can differ per signed-in user.

Can I fetch the client from the container instead of injecting it?

Both services are public, so $container->get(AdobeSignManager::class) works, which is handy in tests. In application code, inject; it keeps dependencies visible and testable.

How do I use the client in a console command?

Inject AdobeSignManager into the command's constructor and call client() (Integration Key) or forUser($key) (OAuth) in execute(). There is no signed-in user in the console, so client() with auth: oauth throws.

Can Twig templates call Acrobat Sign?

Don't. Templates render data; fetch status or signing URLs in the controller and pass them in. An API call hidden in a template is slow, untestable and fails at render time.

How do I upload a file that lives in Flysystem or S3?

Open a read stream and pass it with a name: upload($filesystem->readStream($path), basename($path), 'application/pdf'). The SDK accepts stream resources and PSR-7 streams.