Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Guides

Async with Messenger

Adobe gives your webhook 5 seconds. Downloading a signed PDF, calling the API or emailing your team doesn't fit in that, and shouldn't be retried by Adobe if it fails. Switch on Messenger dispatch and the bundle puts every new notification on your bus as a plain message, which a worker handles with Messenger's retries, failure transport and monitoring.

Why a message, and not only an event

The webhook route always dispatches WebhookReceivedEvent synchronously, inside Adobe's request. That is the right place for small, local work. Everything else belongs in a worker, for three reasons:

  • Time. A worker has minutes, not seconds. Adobe gets its 200 immediately.
  • Retries you control. If your handler fails, Messenger retries it on your schedule and parks it in a failure transport; Adobe isn't involved, and its retry of a delivery you already acknowledged would be dropped as a duplicate anyway.
  • Isolation. A slow or broken handler can't make Adobe disable your webhook.

Enabling it

composer require symfony/messenger
config/packages/adobe_sign.yaml
adobe_sign:
    webhooks:
        messenger: true            # shorthand for { enabled: true, bus: messenger.default_bus }

From now on, for every new notification (not for the verification GET, not for duplicates), the webhook controller dispatches WebhookReceivedEvent first and then an AdobeSignWebhookMessage on the bus. With Messenger not installed, the container build fails with a missing messenger.default_bus service, so you can't think it's on when it isn't.

Route it or handle it, before you enable it

A message with no routing runs its handlers synchronously, inside the webhook request. A message with no routing and no handler makes Messenger throw NoHandlerForMessageException: the webhook answers 500 to every delivery until you deploy a handler. Nothing is lost while Adobe keeps retrying (the bundle doesn't record a notification it failed to handle), but Adobe gives up after 72 hours. Deploy the routing and at least one handler together with messenger: true.

Choosing the bus

The default is messenger.default_bus. If your app has several buses, name the one to use; setting bus also enables the feature:

config/packages/adobe_sign.yaml and messenger.yaml
adobe_sign:
    webhooks:
        messenger:
            bus: event.bus

framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus: ~
            event.bus:
                default_middleware:
                    enabled: true
                    allow_no_handlers: true    # an event with no subscriber is not an error

An event bus with allow_no_handlers fits the semantics (a notification is an event; zero or more handlers may care) and removes the "no handler" failure mode above.

The message

Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage deliberately carries only plain data, so every transport and serializer can carry it:

MemberReturnsMeaning
$payloadarrayThe notification body exactly as Adobe sent it, decoded from JSON (readonly).
event()WebhookEventRebuilds the typed core event (AgreementEvent, MegaSignEvent, …) from the payload. Cheap; call it in the handler.
notificationId()?stringAdobe's webhookNotificationId, unique per notification. Useful as an idempotency key.

Why not put the typed event object in the message? Because the core's events are rich PHP objects tied to SDK classes. A plain array survives PHP serialisation, JSON serialisation with Symfony's Serializer, a transport shared with another language, and SDK upgrades between dispatch and handling. The bundle's own test serialises and unserialises the message and checks that event() still produces the same typed event.

Routing to an async transport

config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'     # doctrine://default, redis://..., amqp://...
                retry_strategy:
                    max_retries: 5
                    delay: 10000
                    multiplier: 3
                    max_delay: 3600000
            failed: 'doctrine://default?queue_name=failed'
        routing:
            Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage: async

Dispatching to the transport is part of the webhook request, so choose a transport that is reliably up: if send() fails, the webhook answers 500 and Adobe delivers the notification again later (see Webhooks). The Doctrine transport in your main database is a sensible default: if it is down, so is the rest of your app. Check the routing with php bin/console debug:messenger.

Writing handlers

src/MessageHandler/NotifySalesOnRejection.php
use App\Repository\ContractRepository;
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Conceptlz\AdobeSign\Webhook\{AgreementEvent, EventType};
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class NotifySalesOnRejection
{
    public function __construct(
        private readonly ContractRepository $contracts,
        private readonly MailerInterface $mailer,
    ) {
    }

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

        $contract = $this->contracts->findOneBy(['agreementId' => $event->agreementId()]);
        if ($contract === null) {
            return;
        }

        $this->mailer->send((new TemplatedEmail())
            ->to($contract->getOwnerEmail())
            ->subject("{$contract->getTitle()} was declined")
            ->htmlTemplate('emails/contract_rejected.html.twig')
            ->context(['contract' => $contract, 'by' => $event->actingUserEmail]));
    }
}
  • One message, many handlers. Every #[AsMessageHandler] for AdobeSignWebhookMessage runs for every notification. Filter by event type and return early; it keeps each handler small and independently testable.
  • Unknown agreements are normal. Account-wide webhooks deliver agreements created outside your app. Return quietly.
  • Confirm with the API before acting on money or files. Payloads aren't signed. The walkthrough's handler re-reads the agreement before downloading.
  • Inject AdobeSignManager, not AdobeSign, in handlers that call Adobe, and call client() inside __invoke(); see kernel.reset.

OAuth in handlers

A worker has no signed-in user, so client() with auth: oauth throws. Find out who owns the agreement from your own records, then act as them:

$contract = $this->contracts->findOneBy(['agreementId' => $event->agreementId()]);
$client = $this->adobeSign->forUser($contract->getSenderKey());   // stored from userKey() when sending

The payload also names the sender ($event->applicableUsers, $event->initiatingUserEmail), but those are Adobe's identities, not your token keys, and payloads aren't signed; your own record is the trustworthy link.

Retries and the failure transport

What happens when a handler throws depends on what it throws:

SituationDo thisEffect
Adobe is briefly unavailable, a timeout (TransportException), a 5xxLet it throw.Retried per retry_strategy, then moved to failed.
RateLimitException with a long retryAfterRe-dispatch with new DelayStamp($e->retryAfter * 1000) and return.Comes back when Adobe allows, without using a retry.
NotFoundException (agreement deleted), AccountCapabilityException, a bug in the payloadWrap in UnrecoverableMessageHandlingException.No retries; straight to failed for a human to look at.
OAuthException in an OAuth handlerMark the connection broken in your app, notify the user, return.Retrying can't fix a revoked token.
Classifying exceptions in a handler
use Conceptlz\AdobeSign\Exception\{AccountCapabilityException, NotFoundException, RateLimitException};
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Stamp\DelayStamp;

try {
    $this->process($message);
} catch (RateLimitException $e) {
    $this->bus->dispatch($message, [new DelayStamp(($e->retryAfter ?? 60) * 1000)]);
} catch (NotFoundException|AccountCapabilityException $e) {
    throw new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
}
php bin/console messenger:failed:show            # what failed, with the exception
php bin/console messenger:failed:retry           # after fixing the cause

Idempotency

The bundle drops duplicate deliveries at the route. Messenger itself is at-least-once: a worker can crash after doing the work but before acknowledging the message, and you can replay messages from the failure transport. So a handler must be safe to run twice for the same notification:

  • Check state first. "Is this contract already signed?" turns a second run into a no-op. This is the simplest and usually sufficient pattern.
  • Make writes idempotent. Writing the same file to the same path twice is harmless; inserting a second row is not. Use unique constraints (for example on notificationId() in an audit table).
  • Guard one-off side effects. For emails or billing, record that you did it in the same transaction, or take a lock with the Lock component keyed on notificationId().

Running workers

php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M -vv
/etc/supervisor/conf.d/messenger.conf
[program:messenger-async]
command=php /var/www/app/bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=0

On every deploy, run php bin/console messenger:stop-workers after switching code; the supervisor starts new workers with the new code and configuration. Workers started before a configuration change keep the old values until they restart.

How kernel.reset keeps clients fresh

A worker is one long-lived container. After each message, Messenger resets every service tagged kernel.reset (unless you pass --no-reset). AdobeSignManager is one of them: its reset() drops the cached default client, all forUser() clients and the OAuth client. The next message gets new clients that read the token store again, so a token refreshed by another process is picked up, and state from one message (such as remembered ETags) doesn't leak into the next. The data-centre lookup lives in cache_pool and survives the reset, so fresh clients don't call GET /baseUris again.

The reset can only clear what the manager holds. A handler that received AdobeSign in its constructor keeps that one instance for the worker's lifetime. That's why handlers should inject the manager and ask it for a client per message.

Testing with the in-memory transport

config/packages/messenger.yaml
when@test:
    framework:
        messenger:
            transports:
                async: 'in-memory://'
tests/Functional/WebhookMessengerTest.php
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;

final class WebhookMessengerTest extends WebTestCase
{
    public function test_a_notification_becomes_one_message(): void
    {
        $client = static::createClient();
        $client->disableReboot();

        $client->request('POST', '/adobe-sign/webhook', server: [
            'HTTP_X_ADOBESIGN_CLIENTID' => 'test-client-id',
            'CONTENT_TYPE' => 'application/json',
        ], content: json_encode([
            'event' => 'AGREEMENT_WORKFLOW_COMPLETED',
            'webhookNotificationId' => 'n-1',
            'agreement' => ['id' => 'agr-1', 'status' => 'SIGNED'],
        ]));
        self::assertResponseIsSuccessful();

        /** @var InMemoryTransport $transport */
        $transport = static::getContainer()->get('messenger.transport.async');
        $sent = $transport->getSent();
        self::assertCount(1, $sent);

        $message = $sent[0]->getMessage();
        self::assertInstanceOf(AdobeSignWebhookMessage::class, $message);
        self::assertSame('n-1', $message->notificationId());
        self::assertInstanceOf(AgreementEvent::class, $message->event());
    }
}

Assert right after the request: the in-memory transport is reset between requests like other resettable services. To run handlers inline instead, use 'sync://' as the test transport. For handler unit tests, construct the message yourself (new AdobeSignWebhookMessage($payload)) and call the handler; see testing your own handlers.

Common questions

Do the event and the message both fire? In which order?

Yes, both, once per new notification: WebhookReceivedEvent first (synchronously), then the message. Use the event for tiny, local work and the message for everything else, or ignore the event entirely.

Can I enable Messenger without an async transport?

Technically yes (handlers run inside the webhook request), but then you gain nothing over a listener and keep the 5-second limit. Route the message to an async transport.

Can I use a JSON serializer for the transport?

Yes. The message holds only a readonly array named payload, which Symfony's Serializer can normalise and rebuild through the constructor. The default PHP serializer works as well.

Can I replay old notifications?

From the failure transport, yes (messenger:failed:retry). Adobe doesn't offer a replay of past deliveries, so if you need history, store $message->payload in your own table in a handler.

How do I catch notifications that never arrived?

Add a scheduled task (Symfony Scheduler or cron) that lists your contracts still "sent" after a day or two and checks their status with agreements()->get(). It covers disabled webhooks, outages longer than 72 hours, and the rare dropped notification.