Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Start here

Configuration

Everything the bundle does is driven by the adobe_sign configuration tree. This page lists every key with its default and what it changes, shows how to feed secrets through %env()%, and gives complete examples for local development, a multi-server production setup and a SaaS where customers connect their own accounts.

How configuration is read

The bundle is an AbstractBundle whose extension alias is adobe_sign. Symfony reads your YAML (or PHP) files, merges them per environment, validates them against the tree the bundle declares, and the bundle turns the result into service definitions when the container is compiled. Three consequences follow:

  • Types are checked at build time. A string where an integer belongs (base_uri_ttl, dedupe_ttl, retries.max) fails cache:clear with a clear message. With an environment variable, cast it: '%env(int:ADOBE_SIGN_DEDUPE_TTL)%'.
  • Values are checked when used. auth and signature_level are plain strings in the tree. The manager validates them the first time it builds a client and throws MisconfiguredException naming the key, so a typo can't silently fall back to a default.
  • Services are referenced by id. cache_pool, oauth.token_cache_pool, http_client, oauth.token_store, oauth.user_key_resolver and webhooks.messenger.bus take service ids. A missing id fails the container build, except http_client, which falls back to discovery (see the HTTP client).

Two console commands show what Symfony actually resolved:

php bin/console config:dump-reference adobe_sign   # the tree with defaults and help texts
php bin/console debug:config adobe_sign            # your merged values for the current env

The full tree with defaults

Every value below is the default. You only write the keys you change; for an Integration Key setup that is usually just integration_key.

config/packages/adobe_sign.yaml (all keys, default values)
adobe_sign:
    auth: integration_key            # "integration_key" or "oauth"
    integration_key: ~               # usually '%env(ADOBE_SIGN_INTEGRATION_KEY)%'

    oauth:
        client_id: ~
        client_secret: ~
        redirect_uri: ~              # default: absolute URL of route adobe_sign_oauth_callback
        scopes:
            - 'user_login:self'
            - 'agreement_read:self'
            - 'agreement_write:self'
            - 'agreement_send:self'
            - 'webhook_read:self'
            - 'webhook_write:self'
        redirect_after: /
        user_key_resolver: ~         # service id; default keys tokens by getUserIdentifier()
        token_store: cache           # "cache", "doctrine" or a TokenStore service id
        token_cache_pool: cache.app
        encryption_key: '%kernel.secret%'
        doctrine:
            connection: default
            table: adobe_sign_tokens

    signature_level: ses             # ses, aes or qes (any case)
    qualified_issuers: []

    bootstrap_url: 'https://api.adobesign.com'
    base_uri_ttl: 86400              # seconds
    retries:
        max: 3
        max_delay_seconds: 60.0
    cache_pool: cache.app
    http_client: ~                   # PSR-18 service id

    webhooks:
        client_ids: []               # empty: [oauth.client_id]
        dedupe_ttl: 259200           # 72 hours
        messenger:
            enabled: false
            bus: messenger.default_bus

Authentication

KeyDefaultEffect
authintegration_keyWhat the default client (AdobeSign service, $manager->client()) uses. integration_key: the key below. oauth: the OAuth tokens of the user currently signed in. Case and surrounding spaces are ignored; any other word throws MisconfiguredException.
integration_keynullThe Integration Key. Required when auth is integration_key and you use the default client. Empty or null throws MisconfiguredException on first use, not at boot, so commands that never touch Acrobat Sign keep working.

auth: oauth changes only what the default client is. $manager->forUser($user) works in either mode, as long as the oauth section is filled in. A common mix: keep auth: integration_key for system jobs and call forUser() where a user sends from their own account. See injecting the manager.

OAuth application and routes

KeyDefaultEffect
oauth.client_idnullYour Acrobat Sign API application's client id. Also the default webhook allow-list, because Adobe sends it in X-AdobeSign-ClientId.
oauth.client_secretnullThe application's secret. Both id and secret are needed for anything OAuth; otherwise MisconfiguredException.
oauth.redirect_urinullWhere Adobe sends users back. When null, the manager generates the absolute URL of adobe_sign_oauth_callback with the router, from the current request's scheme and host. Set it explicitly when the app sits behind a proxy or is reached under several hosts. Must match the URI registered at Adobe exactly.
oauth.scopessix :self scopesThe scopes requested on the consent screen. A list replaces the default entirely. See choosing scopes.
oauth.redirect_after/Path or URL the callback redirects to after storing the tokens. An empty string also means /.
oauth.user_key_resolvernullService id of a UserKeyResolver. The default resolver keys tokens by UserInterface::getUserIdentifier(). Use your own to share one connection per team. See user keys.

Token storage

KeyDefaultEffect
oauth.token_storecachecache: CacheTokenStore in a PSR-6 pool. doctrine: DoctrineDbalTokenStore in a table (needs doctrine/dbal). Anything else is taken as the id of your own service implementing Conceptlz\AdobeSign\Auth\TokenStore.
oauth.token_cache_poolcache.appPool for token_store: cache. Use a persistent, shared pool (Redis, database) without a default lifetime. Never an array pool.
oauth.encryption_key%kernel.secret%Secret the stored tokens are encrypted with (a libsodium key is derived from it). Changing it makes every stored token unreadable; users then reconnect. Empty throws MisconfiguredException on first use.
oauth.doctrine.connectiondefaultDBAL connection name for token_store: doctrine; the bundle uses service doctrine.dbal.<name>_connection.
oauth.doctrine.tableadobe_sign_tokensTable name for token_store: doctrine.

The trade-offs between the stores, and key rotation, are covered in Token storage & Doctrine.

Signature level

KeyDefaultEffect
signature_levelsesDefault level for every agreement the bundle's clients create from an AgreementInfo builder: ses, aes or qes, any case. AES and QES make Adobe require a certificate-based digital signature from each signer that doesn't choose its own level. Any other value throws MisconfiguredException when a client is built.
qualified_issuers[]Certificate issuer names you accept as qualified. Used by $manager->signatureShortfalls() to tell a QES from an AES. Empty means no signature can be confirmed as QES.

See Signature levels for overrides per agreement and per signer.

Data centres, cache, HTTP and retries

KeyDefaultEffect
cache_poolcache.appPSR-6 pool for the data-centre lookup (wrapped as PSR-16, service adobe_sign.cache) and for webhook de-duplication. Share it between servers in production.
bootstrap_urlhttps://api.adobesign.comWhere the SDK asks GET /baseUris which data centre the account lives on. Change it only for accounts in a separate Adobe environment.
base_uri_ttl86400Seconds to cache that answer. A 403 INVALID_API_ACCESS_POINT triggers rediscovery anyway.
http_clientnullService id of a PSR-18 client. Null: psr18.http_client if registered, otherwise discovery.
retries.max3How many times the SDK retries a 429 or 503 before throwing. 0 disables retries.
retries.max_delay_seconds60.0The longest single wait the SDK accepts, from Retry-After or its own backoff. A longer Retry-After surfaces as RateLimitException so a worker can reschedule instead of blocking.

cache.app is per machine by default

Out of the box cache.app is a filesystem pool under var/cache. That's fine on one server. Behind a load balancer each server keeps its own copy, so a webhook retry that lands on another server is not recognised as a duplicate, and OAuth tokens stored on one server are invisible to the others. Point cache_pool (and oauth.token_cache_pool, if you store tokens in a cache) at a shared pool.

config/packages/cache.yaml: dedicated pools for the bundle
framework:
    cache:
        pools:
            adobe_sign.pool:              # data centres + webhook dedupe
                adapter: cache.adapter.redis
                provider: '%env(REDIS_URL)%'
            adobe_sign.tokens:            # OAuth tokens: no default_lifetime!
                adapter: cache.adapter.redis
                provider: '%env(REDIS_URL)%'
config/packages/adobe_sign.yaml
adobe_sign:
    cache_pool: adobe_sign.pool
    oauth:
        token_cache_pool: adobe_sign.tokens

Logging

There is no logging key. The manager and the webhook controller are tagged with the Monolog channel adobe_sign, so with MonologBundle their logs go to monolog.logger.adobe_sign, which you route like any channel. The SDK logs each request at debug, throttling retries at warning and requests that could not be sent at all at error; it never logs headers or bodies. The webhook controller logs rejected client ids (warning) and unreadable bodies (error). See Logging.

Webhooks

KeyDefaultEffect
webhooks.client_ids[]Client ids allowed in X-AdobeSign-ClientId. Empty: [oauth.client_id]. Neither set: the webhook route answers 500 with MisconfiguredException. A non-empty list replaces the OAuth client id; include it yourself if you need both.
webhooks.dedupe_ttl259200Seconds to remember a notification id, stored in cache_pool. Adobe retries failed deliveries for up to 72 hours, hence the default.
webhooks.messenger.enabledfalseAlso dispatch an AdobeSignWebhookMessage per new notification. webhooks: { messenger: true } is shorthand for enabling it.
webhooks.messenger.busmessenger.default_busService id of the bus to dispatch to. Setting a bus enables Messenger dispatch too.

Environment variables and secrets

Any scalar value can come from the environment. The bundle doesn't read environment variables itself; it receives whatever your config resolves to. The usual set:

config/packages/adobe_sign.yaml
adobe_sign:
    integration_key: '%env(ADOBE_SIGN_INTEGRATION_KEY)%'
    signature_level: '%env(default:adobe_sign_default_level:ADOBE_SIGN_SIGNATURE_LEVEL)%'
    oauth:
        client_id: '%env(ADOBE_SIGN_CLIENT_ID)%'
        client_secret: '%env(ADOBE_SIGN_CLIENT_SECRET)%'
        encryption_key: '%env(ADOBE_SIGN_TOKEN_KEY)%'
    webhooks:
        client_ids: ['%env(ADOBE_SIGN_CLIENT_ID)%']   # one variable per list item
        dedupe_ttl: '%env(int:ADOBE_SIGN_DEDUPE_TTL)%'

parameters:
    adobe_sign_default_level: ses
  • List keys (client_ids, qualified_issuers, scopes) can't take one variable for the whole list: Symfony rejects "a dynamic value" on array nodes, so '%env(csv:VAR)%' fails the build. Put one %env()% per item instead.
  • int: is needed for integer keys, because environment variables are strings.
  • default:param:VAR falls back to a parameter when the variable isn't set.
  • Secrets (php bin/console secrets:set NAME) are read through the same %env()% syntax, so production can use the vault without changing the config.

Examples per environment

Symfony merges config/packages/adobe_sign.yaml with config/packages/<env>/adobe_sign.yaml, or with a when@<env>: block in the same file. Use whichever your project already uses.

Local development

config/packages/dev/adobe_sign.yaml
adobe_sign:
    # A developer account: agreements get "[DEMO USE ONLY]" and aren't legally meant.
    integration_key: '%env(ADOBE_SIGN_INTEGRATION_KEY)%'
    oauth:
        # The tunnel's host, so Adobe's redirect matches what you registered.
        redirect_uri: 'https://my-tunnel.example.com/adobe-sign/oauth/callback'

The test environment

config/packages/adobe_sign.yaml (when@test block)
when@test:
    adobe_sign:
        integration_key: 'test-key'
        http_client: test.adobe_sign_http     # a mock PSR-18 client, see Testing
        webhooks:
            client_ids: ['test-client-id']

    services:
        test.adobe_sign_http:
            class: Http\Mock\Client
            public: true

The Testing page explains why the mock is public and how to keep it alive between requests.

Production on several servers

config/packages/prod/adobe_sign.yaml
adobe_sign:
    integration_key: '%env(ADOBE_SIGN_INTEGRATION_KEY)%'
    signature_level: aes
    cache_pool: adobe_sign.pool              # Redis, shared by every web server and worker
    retries:
        max: 5
        max_delay_seconds: 30                # longer waits become RateLimitException
    webhooks:
        client_ids: ['%env(ADOBE_SIGN_CLIENT_ID)%']
        messenger: true                      # route the message to an async transport

A SaaS where customers connect their own accounts

config/packages/adobe_sign.yaml
adobe_sign:
    auth: oauth                              # AdobeSign service = the signed-in user's account
    oauth:
        client_id: '%env(ADOBE_SIGN_CLIENT_ID)%'
        client_secret: '%env(ADOBE_SIGN_CLIENT_SECRET)%'
        redirect_uri: 'https://app.example.com/adobe-sign/oauth/callback'
        redirect_after: /settings/integrations
        token_store: doctrine
        encryption_key: '%env(ADOBE_SIGN_TOKEN_KEY)%'   # rotate APP_SECRET without disconnecting users
        user_key_resolver: App\AdobeSign\TeamKeyResolver
        scopes:
            - 'user_login:self'
            - 'agreement_read:account'
            - 'agreement_write:account'
            - 'agreement_send:account'
            - 'webhook_read:account'
            - 'webhook_write:account'
    webhooks:
        messenger: true

Changing values at runtime

You can't. The configuration is compiled into the container, and the manager receives it as a constructor argument. That is deliberate: credentials in one place, validated once. If different tenants need different Integration Keys, build a core client per tenant with the core's named constructor and pass the bundle's services in, rather than mutating configuration:

use Conceptlz\AdobeSign\AdobeSign;

$client = AdobeSign::withIntegrationKey($tenant->adobeSignKey(), cache: $psr16Cache, logger: $logger);

Inject $psr16Cache as the service adobe_sign.cache (for example with #[Autowire(service: 'adobe_sign.cache')]) so the data-centre lookup is shared.

Common questions

Does the file have to be called adobe_sign.yaml?

No. Symfony reads every file in config/packages/; what matters is the root key adobe_sign:. Naming the file after the key is just the convention.

Can I write the configuration in PHP instead of YAML?

Yes. In config/packages/adobe_sign.php, return a closure that receives a ContainerConfigurator and call $container->extension('adobe_sign', [...]) with the same keys. The bundle's own test kernel configures itself this way.

I changed .env.local but nothing changed. Why?

In prod, values may be dumped into .env.local.php (composer dump-env prod), and long-running processes keep what they read at start. Re-dump the env file, clear the cache, and restart PHP-FPM workers, FrankenPHP workers and Messenger consumers.

Can one app use both an Integration Key and OAuth?

Yes. Set integration_key and the oauth section, keep auth: integration_key, and call $manager->forUser($user) where a user's own account should send. The default client stays the key.

How do I catch configuration mistakes before production?

Run php bin/console lint:container and cache:clear in CI for type and service-id errors, and adobe-sign:verify after deploy for credentials and the auth/signature_level values, which are checked when the first client is built.