Adobe Sign for Laravelconceptlz/adobe-sign-laravel GitHub

Help

FAQ

The questions Laravel developers ask most, answered in a sentence first and in detail after, with a link to the page that covers each topic in depth. Every answer can be linked to directly.

Installation & versions

Which Laravel and PHP versions are supported?

Laravel 12 and 13 on PHP 8.2 or newer.

The package requires illuminate/* ^12.0 || ^13.0, and its CI runs the test suite on both (Testbench 10 and 11). Older Laravel versions can use the framework-agnostic core directly. See Requirements.

Do I need to install conceptlz/adobe-sign separately?

No. Requiring conceptlz/adobe-sign-laravel installs the core at a compatible version.

You use the core's classes (builders, resources, exceptions) directly in your code, so it is fine to import from Conceptlz\AdobeSign\…; you just don't need a second composer require. See Install in five steps.

How do the two packages' versions relate?

They share one version number and are always released together; the current release is 0.4.0.

The bridge requires the core at ^0.4. Before 1.0, a minor bump (0.4 → 0.5) may contain breaking changes, so update both with composer update conceptlz/adobe-sign-laravel conceptlz/adobe-sign and read the changelog. See Upgrading.

Is this an official Adobe package?

No. It is developed independently and is not affiliated with or endorsed by Adobe.

It talks to Adobe's public Acrobat Sign REST API v6 with your own account and credentials. Adobe and Acrobat Sign are trademarks of Adobe Inc.

Do I need a paid Acrobat Sign account to start?

No. A free developer account is enough to build and test.

Agreements from developer accounts get [DEMO USE ONLY] prepended to their name and aren't meant for real contracts. For production you need an account whose plan includes API access; some features (hybrid routing, certain identity checks, digital signatures) depend on the plan and its settings.

Auth & OAuth

Should I use an Integration Key or OAuth?

Integration Key if everything is sent from one account you control; OAuth if your users send from their own Acrobat Sign accounts.

The key is one .env line and never expires. OAuth needs an API application, a redirect URI and the token table, but agreements then live in each user's account. You can mix them: the key as default client, forUser() for connected users. See Integration Key or OAuth?

Can each tenant use its own Integration Key?

Yes, by building a core client per tenant instead of using the configured default.

The core client's Conceptlz\AdobeSign\AdobeSign::withIntegrationKey($tenant->key, cache: Cache::store('redis'), logger: Log::channel('adobe-sign')) gives you a client with Laravel's cache and logger. Don't rewrite config('adobe-sign.integration_key') per request; the scoped manager reads config once. See Changing values at runtime.

Where are OAuth tokens stored, and are they encrypted?

In the adobe_sign_tokens table, encrypted with your APP_KEY.

The AdobeSignToken model uses Laravel's encrypted cast for both tokens and hides them from serialisation. You can bind your own TokenStore to keep them elsewhere. See Where tokens live.

What happens when a user's access token expires?

Nothing you need to handle: the SDK refreshes it shortly before expiry and saves the new one.

Only a dead refresh token needs the user: unused for 60 days, or revoked. Then calls throw OAuthException and the user must connect again. See Refreshing tokens.

Can a whole team share one Acrobat Sign connection?

Yes. Map users to a team key once with AdobeSignManager::resolveUserKeyUsing().

For example fn ($user) => 'team:'.$user->current_team_id in a service provider's boot(). The callback then stores the token under the team key and forUser() finds it for every member. See One connection per team.

Can I send on behalf of a colleague without OAuth?

Yes, with an account admin's Integration Key: AdobeSign::asUser('jane@example.com').

The SDK sends the x-api-user header and Adobe treats Jane as the sender. It returns a new client and leaves the default one unchanged. Without admin rights, Adobe rejects the header. See Acting as another user.

Sending & storing

How do I send a PDF that is stored on S3?

Pass a read stream to upload() with a file name.

AdobeSign::transientDocuments()->upload(Storage::disk('s3')->readStream($path), fileName: 'contract.pdf', mimeType: 'application/pdf'). The body is streamed, so large files aren't loaded into memory. See Upload from a Storage disk.

How do I make two people sign at the same time?

Give their participant sets the same order() number.

Orders must run 1, 2, 3 without gaps. Mixing a sequential step with a parallel one ("client, then CFO and legal together") is hybrid routing, which must be enabled on the account, or Adobe answers HYBRID_ROUTING_NOT_ALLOWED. See Create and send an agreement and the core's signing order page.

Can people sign inside my app instead of from an email?

Yes: get their link with agreements()->signingUrlFor($id, $email) and redirect or embed.

It returns null when it isn't that person's turn, and throws NotFoundException for a few seconds after creation while Adobe processes the document. The walkthrough's signingUrl() action handles both. See the controller.

What's the best way to keep the signed PDF?

Download it in a queued job when the completion webhook arrives, and stream it to a Storage disk.

combinedDocument($id, attachAuditReport: true) gives the signed PDF with Adobe's audit trail appended; Storage::writeStream() copies it in chunks. Confirm the status with the API first. See The download job.

Can I cancel an agreement or send a reminder from Laravel?

Yes: agreements()->cancel($id, comment: '…') and agreements()->sendReminder($id).

A reminder without participant ids goes to whoever's turn it is. Both are good candidates for queued jobs triggered from your admin UI. See Track progress and the core's managing agreements page.

Webhooks & queues

Why must webhook listeners be queued?

Because Adobe gives the whole request 5 seconds, and synchronous listeners run inside it.

A slow answer counts as a failed delivery: Adobe retries, and after 72 hours of failures it disables the webhook. With ShouldQueue the package answers in milliseconds and your work happens on a worker. See Writing queued listeners.

Do I need to exclude the webhook route from CSRF verification?

No. The route is registered outside the web middleware group, so CSRF and sessions never run on it.

Keep web and auth out of webhooks.middleware, and exclude the path from any global middleware that needs a session. See CSRF, sessions and auth.

Which value goes in ADOBE_SIGN_WEBHOOK_CLIENT_IDS?

The client id of the Adobe API application that created the webhooks, as Adobe sends it in X-AdobeSign-ClientId.

For apps that create webhooks through their own OAuth application you can leave it empty: it falls back to ADOBE_SIGN_CLIENT_ID. If unsure, log the header once. See The client-id allow-list.

What happens when Adobe sends the same notification twice?

The second delivery gets a 200 but doesn't fire WebhookReceived again.

The notification id is remembered in the cache for 72 hours (webhooks.dedupe_ttl). On several servers this only works with a shared cache store. Listeners should still be idempotent. See Duplicate deliveries.

Can I trust what a webhook payload says?

Treat it as a hint and confirm with the API before doing anything irreversible.

Adobe doesn't sign payloads. The client-id allow-list stops casual forgeries, but re-fetching the agreement (agreements()->get($id)->isSigned()) is cheap and settles it. See Why the job is written this way.

How do I receive webhooks while developing on localhost?

Use a tunnel such as ngrok or cloudflared and set APP_URL to its HTTPS address.

Adobe only calls public HTTPS URLs. Run a queue worker too, and delete webhooks pointing at old tunnel addresses. To test your listener without Adobe, post a payload to the route yourself. See Local development with a tunnel.

Signature levels

How do I require AES or QES for every agreement?

Set ADOBE_SIGN_SIGNATURE_LEVEL=aes or qes.

Every agreement built with AgreementInfo then asks each signer for a certificate-based digital signature, unless it sets its own level. The account needs digital signatures enabled, and for QES a qualified trust service provider. See Set the project's default level.

Can signers of one agreement have different levels?

Yes. ParticipantSetInfo::signatureLevel() overrides the agreement's level for one signer, in either direction.

Only signer sets can be held to AES or QES; asking an approver for it throws before sending. See Override per signer.

How do I check that an agreement really reached QES?

Call AdobeSign::signatureShortfalls($id); an empty array means every signer met the configured level.

Set ADOBE_SIGN_QUALIFIED_ISSUERS to the certificate issuers you accept as qualified; without them nothing can count as QES. The core's signature levels page explains the legal side. See Check what each signer reached.

Testing & deployment

How do I test my code without calling Adobe?

Bind a fake PSR-18 client: $this->app->instance(ClientInterface::class, $mock).

The SDK then sends every request to it, so you can queue Adobe's responses and assert on what was sent. Webhook routes need no fake; just post to them with the client-id header. See Testing your application.

Does Http::fake() intercept the SDK's requests?

No. The SDK uses a PSR-18 client directly, not Laravel's Http client.

Use php-http/mock-client or Guzzle's MockHandler bound as Psr\Http\Client\ClientInterface. See Choose a fake client.

What changes when the app runs on several servers?

Use a shared cache store, the same APP_KEY everywhere, and trusted proxies.

ADOBE_SIGN_CACHE_STORE=redis (or another shared store) makes webhook dedupe work across servers and shares the data-centre lookup. A shared APP_KEY lets every server decrypt OAuth tokens. Trusted proxies make route() produce https:// URLs behind a load balancer. See Cache store not shared.

Is the package safe to use with Laravel Octane?

Yes. AdobeSignManager is bound scoped, so it is rebuilt for every request and no user's client leaks into the next.

Two things to know: resolveUserKeyUsing() is static and process-wide, so set it once at boot, not per request; and workers keep their config until php artisan octane:reload. See What the container holds.

Can I run it on Laravel Vapor or another serverless platform?

Nothing in the package needs a long-lived process, but serverless changes a few defaults you rely on.

Use a shared cache store (DynamoDB or Redis) for dedupe and the data-centre cache; store signed PDFs on S3, not local disk, which doesn't persist between invocations; and keep large documents out of queue messages, since queues such as SQS have size limits, so don't ask Adobe to include signed documents in webhook payloads. Request bodies are also limited on such platforms, so large PDFs are usually uploaded straight to S3 first and passed to upload() as a read stream. Cold starts eat into Adobe's 5-second webhook budget, another reason to keep listeners queued. See Writing queued listeners.

What should a deploy script include?

config:cache, migrate, queue:restart (and octane:reload if used), then adobe-sign:verify as a smoke test.

The verify command exits non-zero on bad credentials, so it can fail the pipeline before users notice. Register account-wide webhooks only once, after the public URL is live. See Check the connection.

How do I rotate the Integration Key without downtime?

Create the new key in Acrobat Sign, deploy it, verify, then revoke the old one.

Update ADOBE_SIGN_INTEGRATION_KEY, re-cache config, restart queue and Octane workers, and run php artisan adobe-sign:verify. The data-centre cache is keyed by a hash of the credentials, so the new key triggers one fresh lookup. Only then revoke the old key in Acrobat Sign. See Authentication settings.