Adobe Sign for Laravelconceptlz/adobe-sign-laravel GitHub

Start here

Configuration

Everything the package does is driven by config/adobe-sign.php. This page lists every key with its .env variable, its default and what it changes, and explains when you would touch it.

How configuration is read

The service provider merges the package's config/adobe-sign.php under the adobe-sign key, so config('adobe-sign.signature_level') works whether or not you published the file. Values that change between environments (keys, secrets, levels, cache store) read .env; structural values (route paths, middleware, retry counts) are plain config.

AdobeSignManager receives the whole adobe-sign array when it is built. It is bound scoped, which means one instance per HTTP request, per Octane request and per queued job. Two consequences follow:

  • After php artisan config:cache, editing .env has no effect until you cache again. This is standard Laravel behaviour, but it is the most common reason a new key "doesn't work".
  • If you change config at runtime with config([...]), an already-built manager keeps the old values for the rest of that request. Change config before the first call, or see Changing values at runtime.

The routes are registered from config at boot, so oauth.* route settings and webhooks.enabled/path/middleware must be set in the config file (or .env), not at runtime.

The .env variables at a glance

.env (all optional except the credentials you use)
# Authentication
ADOBE_SIGN_AUTH=integration_key          # or oauth
ADOBE_SIGN_INTEGRATION_KEY=

# OAuth application
ADOBE_SIGN_CLIENT_ID=
ADOBE_SIGN_CLIENT_SECRET=
ADOBE_SIGN_REDIRECT_URI=                 # defaults to route('adobe-sign.oauth.callback')
ADOBE_SIGN_SCOPES="user_login:self agreement_read:self agreement_write:self agreement_send:self webhook_read:self webhook_write:self"

# Signature level
ADOBE_SIGN_SIGNATURE_LEVEL=ses           # ses, aes or qes
ADOBE_SIGN_QUALIFIED_ISSUERS=            # comma-separated certificate issuers

# Infrastructure
ADOBE_SIGN_BOOTSTRAP_URL=https://api.adobesign.com
ADOBE_SIGN_CACHE_STORE=                  # empty = default cache store
ADOBE_SIGN_LOG_CHANNEL=                  # empty = default channel, false = off

# Webhooks
ADOBE_SIGN_WEBHOOK_CLIENT_IDS=           # comma-separated; empty = ADOBE_SIGN_CLIENT_ID

Authentication

Key.envDefaultEffect
authADOBE_SIGN_AUTHintegration_keyWhat the default client (the facade, and an injected AdobeSign) authenticates with. oauth makes it the signed-in user's OAuth connection.
integration_keyADOBE_SIGN_INTEGRATION_KEYnullThe account's Integration Key. Required when auth is integration_key and you use the default client.

auth only decides what the default client is. AdobeSign::forUser($user) always uses OAuth, whatever auth says, so an app can send from its own account by default and from a user's account where it matters.

oauth mode needs a signed-in user

With ADOBE_SIGN_AUTH=oauth the default client is built from auth()->user(). In a queued job, a scheduled command or the webhook listener there is no signed-in user, so the default client throws MisconfiguredException. Use AdobeSign::forUser($userId) there instead.

OAuth application and routes

Key.envDefaultEffect
oauth.client_idADOBE_SIGN_CLIENT_IDnullYour Acrobat Sign API application's client id. Also the fallback webhook allow-list.
oauth.client_secretADOBE_SIGN_CLIENT_SECRETnullThe application's client secret. Without id and secret, oauth() and forUser() throw MisconfiguredException.
oauth.redirect_uriADOBE_SIGN_REDIRECT_URInullThe callback URL sent to Adobe. Empty means route('adobe-sign.oauth.callback'). It must match a redirect URI registered on your Adobe application exactly.
oauth.scopesADOBE_SIGN_SCOPESuser_login:self agreement_read:self agreement_write:self agreement_send:self webhook_read:self webhook_write:selfSpace-separated scopes requested on connect, each with an optional :self, :group or :account modifier.
oauth.tablenoneadobe_sign_tokensTable used by the migration and the AdobeSignToken model. Set it before running the migration.
oauth.routesnonetrueRegisters the connect routes. Set false to write your own controller around AdobeSign::oauth().
oauth.route_prefixnoneadobe-sign/oauthURL prefix: the routes become {prefix}/redirect and {prefix}/callback.
oauth.route_middlewarenone['web', 'auth']Middleware for both routes. Keep web (the state lives in the session) and an authentication middleware (tokens are stored under the signed-in user).
oauth.redirect_afternone/Where the callback sends the user after storing the tokens. A path or full URL.

Example: connect from a settings page behind your own guard, and land back on it.

config/adobe-sign.php (excerpt)
'oauth' => [
    // ...
    'route_prefix' => 'settings/integrations/acrobat-sign',
    'route_middleware' => ['web', 'auth', 'verified'],
    'redirect_after' => '/settings/integrations?connected=acrobat-sign',
],

Request broader scopes only when you need them. agreement_read:account, for example, lets an account admin's token read every agreement in the account, and Adobe shows the user exactly what they are granting. The OAuth page covers scope choice.

Signature level

Key.envDefaultEffect
signature_levelADOBE_SIGN_SIGNATURE_LEVELsesDefault level for agreements built with AgreementInfo that don't set their own: ses, aes or qes (case-insensitive). Any other value makes client creation throw MisconfiguredException.
qualified_issuersADOBE_SIGN_QUALIFIED_ISSUERS[]Certificate issuers you accept as qualified, comma-separated in .env. Used by AdobeSign::signatureShortfalls() to decide whether a digital signature counts as QES.
.env for a project that needs qualified signatures
ADOBE_SIGN_SIGNATURE_LEVEL=qes
ADOBE_SIGN_QUALIFIED_ISSUERS="DigiCert,TrustPro QTSP"

The level becomes ClientOptions::$defaultSignatureLevel on every client the manager builds, including forUser() clients. It applies only to agreements passed to agreements()->create() as an AgreementInfo builder; a raw array or a generated model is sent as-is. The Signature levels page explains the levels and per-signer overrides.

Data centres, cache and retries

Key.envDefaultEffect
bootstrap_urlADOBE_SIGN_BOOTSTRAP_URLhttps://api.adobesign.comHost used only for GET /baseUris, which tells the SDK which data centre your account lives on. Change it only for separate Adobe environments such as Gov Cloud.
cache_storeADOBE_SIGN_CACHE_STOREnull (default store)Laravel cache store for the discovered data-centre URL and for webhook dedupe keys. Name any store from config/cache.php, e.g. redis.
base_uri_ttlnone86400Seconds a discovered data-centre URL stays cached. If an account moves, the SDK notices the 403 and rediscovers on its own.
retries.maxnone3Retries for throttled responses (429 and 503). 0 disables retrying.
retries.max_delay_secondsnone60Longest wait per retry. A Retry-After longer than this fails fast instead of blocking (RateLimitException for a 429).

Why the cache matters: Acrobat Sign accounts live on shards (na1, na4, eu1, …) and every call must go to the right one. Without a shared cache, every PHP process would repeat the /baseUris lookup. With the default cache store it happens once per day for the whole app.

Why the retry ceiling matters: retries happen inside the request that made the call. In a web request, sleeping for a minute is worse than failing, so you may want a lower max_delay_seconds for web traffic and to let queued jobs handle RateLimitException with release(), as the download job in the walkthrough does. Without a Retry-After header the SDK backs off exponentially from one second.

Multiple servers need a shared store

On more than one server, point ADOBE_SIGN_CACHE_STORE at a shared store (Redis, Memcached, database, DynamoDB). The data-centre cache works with any store, but webhook dedupe only works across servers if every server sees the same keys.

Logging

Key.envDefaultEffect
log_channelADOBE_SIGN_LOG_CHANNELnullnull logs to your default channel, a channel name logs there, and false disables SDK logging.

The core logs requests and throttling retries. It never passes headers or bodies to the logger, so keys, tokens and document content stay out of your logs. Laravel's env() turns the string false into a boolean, so ADOBE_SIGN_LOG_CHANNEL=false turns logging off. A dedicated channel keeps the noise out of your main log:

config/logging.php (excerpt)
'channels' => [
    // ...
    'adobe-sign' => [
        'driver' => 'daily',
        'path' => storage_path('logs/adobe-sign.log'),
        'level' => 'info',
        'days' => 14,
    ],
],
ADOBE_SIGN_LOG_CHANNEL=adobe-sign

The webhook controller logs rejected and unreadable deliveries through Laravel's default logger (the Log facade), not this channel.

Webhooks

Key.envDefaultEffect
webhooks.enablednonetrueRegisters the adobe-sign.webhook route. Set false if you don't receive webhooks or handle them yourself.
webhooks.pathnoneadobe-sign/webhookURI of the route, which answers GET and POST.
webhooks.middlewarenone[]Middleware for the route. Deliberately not web: Adobe sends no CSRF token or session cookie.
webhooks.client_idsADOBE_SIGN_WEBHOOK_CLIENT_IDS[]Client ids allowed to deliver, comma-separated in .env. Empty means [oauth.client_id]. With neither set, the route answers with a MisconfiguredException.
webhooks.dedupe_ttlnone259200 (72 hours)Seconds a notification id is remembered, so a retried delivery doesn't fire the event twice. Adobe retries for up to 72 hours.

You can add middleware to the webhook route as long as it doesn't need a session, a CSRF token or a logged-in user, and is fast; Adobe allows 5 seconds for the whole answer. A rate limiter or an IP allow-list is fine:

'webhooks' => [
    // ...
    'middleware' => ['throttle:120,1'],
],

The Webhooks page explains the handshake, dedupe and listeners in depth.

Examples per environment

Local development

ADOBE_SIGN_INTEGRATION_KEY=3AAABLblqZhB...developer-account-key...
ADOBE_SIGN_CACHE_STORE=file
ADOBE_SIGN_LOG_CHANNEL=stack
APP_URL=https://warm-otter-42.example-tunnel.dev   # your tunnel, so route('adobe-sign.webhook') is public HTTPS
ADOBE_SIGN_WEBHOOK_CLIENT_IDS=CBJCHBCAABAAdevapp

Production on several servers

ADOBE_SIGN_AUTH=integration_key
ADOBE_SIGN_INTEGRATION_KEY=${SECRET_FROM_YOUR_VAULT}
ADOBE_SIGN_SIGNATURE_LEVEL=aes
ADOBE_SIGN_CACHE_STORE=redis
ADOBE_SIGN_LOG_CHANNEL=adobe-sign
ADOBE_SIGN_WEBHOOK_CLIENT_IDS=CBJCHBCAABAAprodapp

A SaaS where customers connect their own accounts

ADOBE_SIGN_AUTH=oauth
ADOBE_SIGN_CLIENT_ID=CBJCHBCAABAAsaasapp
ADOBE_SIGN_CLIENT_SECRET=${SECRET_FROM_YOUR_VAULT}
ADOBE_SIGN_REDIRECT_URI=https://app.example.com/adobe-sign/oauth/callback
ADOBE_SIGN_CACHE_STORE=redis
# webhook allow-list falls back to ADOBE_SIGN_CLIENT_ID

Changing values at runtime

Because the manager reads config once per scope, a runtime change needs a fresh manager. In tests this is common:

config(['adobe-sign.signature_level' => 'qes']);
$this->app->forgetScopedInstances();                       // next resolve builds a new manager
\Conceptlz\AdobeSign\Laravel\Facades\AdobeSign::clearResolvedInstance(); // and the facade forgets the old one

For per-tenant credentials in production, don't rewrite global config. Build a core client for the tenant with Laravel services passed by name. Laravel's cache repository implements PSR-16 and its logger implements PSR-3, so they plug straight in:

app/Support/TenantAdobeSign.php
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\ClientOptions;
use Conceptlz\AdobeSign\Enum\SignatureLevel;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

final class TenantAdobeSign
{
    public static function for(Tenant $tenant): AdobeSign
    {
        return AdobeSign::withIntegrationKey(
            $tenant->adobe_sign_integration_key,   // e.g. an encrypted model attribute
            cache: Cache::store('redis'),
            logger: Log::channel('adobe-sign'),
            options: new ClientOptions(
                defaultSignatureLevel: SignatureLevel::from(strtoupper($tenant->signature_level)),
            ),
        );
    }
}

The data-centre cache key is a hash of the credentials, so tenants on different shards don't overwrite each other. Webhooks for these tenants still arrive at the package's route; add each tenant application's client id to webhooks.client_ids.

Common questions

I changed .env but the package still uses the old value. Why?

Your config is probably cached. Run php artisan config:clear (or config:cache again on a server). Under Octane or a long-running queue worker, also restart the workers, since they loaded config when they started.

Do I need ADOBE_SIGN_WEBHOOK_CLIENT_IDS if I use OAuth?

Not when the webhooks are created through your OAuth application: an empty list falls back to ADOBE_SIGN_CLIENT_ID. Set it when webhooks come from another application, or when you have no OAuth app at all.

Can I turn off the routes the package registers?

Yes. Set oauth.routes to false to drop the connect routes and webhooks.enabled to false to drop the webhook route. Both are read at boot from the config file. The manager methods (oauth(), webhookHandler()) keep working for your own controllers.

Can I rename the token table?

Yes. Set oauth.table in your published config before running the migration. Both the migration and the AdobeSignToken model read that value.

What happens with an invalid ADOBE_SIGN_SIGNATURE_LEVEL?

Building any client throws MisconfiguredException with the message "ADOBE_SIGN_SIGNATURE_LEVEL must be ses, aes or qes". It fails at the first API call rather than silently sending SES.

When would I change bootstrap_url?

Only when your account lives in a separate Adobe environment, such as Gov Cloud, whose discovery host is not api.adobesign.com. Commercial accounts in any region use the default. More in the core's data centres page.