Adobe Sign for Laravelconceptlz/adobe-sign-laravel GitHub

Guides

Using the client

How to reach the Acrobat Sign client from Laravel code, what each method on AdobeSignManager does, and how the core builders fit into controllers, Storage disks and queued jobs.

Three ways in

There is one configured client per scope (request or job), and three ways to get at it. They all end up at the same object, so pick whichever reads best in the place you're writing code.

You writeYou getBest for
AdobeSign::agreements() (facade)Calls on AdobeSignManager; unknown methods go to the default clientControllers, Blade, quick scripts, Tinker
Type-hint Conceptlz\AdobeSign\AdobeSignThe default client itselfJobs, services, anything you unit-test
Type-hint Conceptlz\AdobeSign\Laravel\AdobeSignManagerThe manager, with forUser(), oauth(), signatureShortfalls(), …Code that works with several users' connections

The facade

The facade class is Conceptlz\AdobeSign\Laravel\Facades\AdobeSign, auto-registered under the global alias AdobeSign. Its short name is the same as the core client class Conceptlz\AdobeSign\AdobeSign, so when a file needs both, alias one of them:

app/Http/Controllers/AgreementStatusController.php
use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign as Sign;

final class AgreementStatusController
{
    public function __invoke(Contract $contract): JsonResponse
    {
        return response()->json([
            'status' => Sign::agreements()->status($contract->adobe_agreement_id),
        ]);
    }
}

The facade's docblock lists every method with its types, so your IDE and static analysis know that Sign::agreements() returns Conceptlz\AdobeSign\Resource\Agreements. The full list is in the reference.

Injecting the client

The service provider binds the core class Conceptlz\AdobeSign\AdobeSign to AdobeSignManager::client(). Type-hint it anywhere Laravel resolves dependencies: controller actions and constructors, job handle() methods, listeners, commands.

app/Http/Controllers/AgreementController.php
use Conceptlz\AdobeSign\AdobeSign;

public function show(string $agreementId, AdobeSign $sign): JsonResponse
{
    $agreement = $sign->agreements()->get($agreementId);

    return response()->json([
        'id' => $agreement->id,
        'name' => $agreement->name,
        'status' => $agreement->status,   // e.g. OUT_FOR_SIGNATURE, SIGNED, CANCELLED
    ]);
}

Injection makes dependencies visible and keeps classes easy to test. Which client you get depends on ADOBE_SIGN_AUTH: the Integration Key client, or with oauth the signed-in user's connection.

Injecting the manager

When one piece of code works with several connections, inject the manager and ask it for the client you need:

use Conceptlz\AdobeSign\Laravel\AdobeSignManager;

public function handle(AdobeSignManager $adobe): void
{
    foreach (User::whereNotNull('adobe_sign_connected_at')->cursor() as $user) {
        if (! $adobe->isConnected($user)) {
            continue;
        }

        foreach ($adobe->forUser($user)->agreements()->all(pageSize: 50) as $summary) {
            // $summary->id, $summary->name, $summary->status
        }
    }
}

What the container holds

AbstractBindingLifetime
Conceptlz\AdobeSign\Laravel\AdobeSignManagerNew manager with the adobe-sign configScoped: one per request, Octane request or queued job
Conceptlz\AdobeSign\AdobeSignAdobeSignManager::client()Resolved each time, but the manager caches the client, so it is the same object within a scope
Conceptlz\AdobeSign\Auth\TokenStoreEloquentTokenStore, unless your app bound one firstSingleton
Psr\Http\Client\ClientInterfaceNot bound by the package. If you bind it, the manager uses it instead of discovery.Yours

"Scoped" is what makes the package safe under Octane and in queue workers: a manager never outlives the request or job that created it, so one user's OAuth client can't leak into the next request. Laravel also clears resolved facade instances between queued jobs, so the facade follows the same rule.

The manager's methods

Every method below is available on the manager, through injection, and through the facade.

client(): Conceptlz\AdobeSign\AdobeSign

The default client. With auth = integration_key it uses ADOBE_SIGN_INTEGRATION_KEY and is built once per scope. With auth = oauth it returns forUser(auth()->user()).

Throws MisconfiguredException when the key is missing, when auth is oauth and nobody is signed in, or when signature_level is invalid.

forUser(Authenticatable|string|int $user): Conceptlz\AdobeSign\AdobeSign

A client that acts with the OAuth tokens this user connected. Pass a user model, or the token key directly (the user's id by default), which is what you want in queued jobs. Tokens refresh automatically and the new token is written back to the store. Clients are cached per user key for the life of the manager.

A user who never connected only fails at the first API call, with OAuthException (code NO_TOKEN), so check isConnected() first. Throws MisconfiguredException when the OAuth client id or secret isn't set.

$id = AdobeSign::forUser($request->user())->agreements()->create($info);
isConnected(Authenticatable|string|int $user): bool

Whether a token is stored for this user. It doesn't call Adobe, so a stored token may still be revoked or past its 60-day refresh window. To prove a connection works, run php artisan adobe-sign:verify --user=ID or make a cheap call such as users()->me().

oauth(): Conceptlz\AdobeSign\Auth\OAuthClient

The core OAuth client for the authorization-code flow, built from oauth.client_id, oauth.client_secret and oauth.redirect_uri (or the adobe-sign.oauth.callback route). The package's routes use it; you need it directly for custom connect flows or to revoke() a token. Throws MisconfiguredException without id and secret.

tokens(): Conceptlz\AdobeSign\Auth\TokenStore

The bound token store: EloquentTokenStore by default. Use it to read or delete a user's token, e.g. when they disconnect. See Disconnecting.

webhookHandler(): Conceptlz\AdobeSign\Webhook\WebhookHandler

A core webhook handler that allows the ids in webhooks.client_ids, or oauth.client_id when that list is empty. The package's webhook route uses it. Throws MisconfiguredException when neither is set.

userKey(Authenticatable|string|int $user): string
static resolveUserKeyUsing(?callable $resolver): void

userKey() turns a user into the key their tokens are stored under. Strings and integers pass through unchanged; a user model goes through the resolver, or defaults to getAuthIdentifier().

resolveUserKeyUsing() changes that mapping for the whole process. It is static, so call it once in a service provider's boot(); pass null to restore the default. A common use is one Acrobat Sign connection per team:

use Conceptlz\AdobeSign\Laravel\AdobeSignManager;

AdobeSignManager::resolveUserKeyUsing(fn ($user) => 'team:'.$user->current_team_id);
signatureLevel(): Conceptlz\AdobeSign\Enum\SignatureLevel
qualifiedIssuers(): list<string>

The project's configured default level (SignatureLevel::SES, AES or QES) and the certificate issuers you accept as qualified. signatureLevel() throws MisconfiguredException for a value other than ses, aes or qes.

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

Checks a completed agreement against a level (the configured one by default) using the configured qualified issuers. Returns one human-readable line per signer who fell short; an empty array means every signer reached the level. Checking QES with an empty qualified_issuers list throws the core InvalidArgumentException. Details on the signature levels page.

__call(string $method, array $arguments): mixed

Anything the manager doesn't define goes to client(). That is why AdobeSign::agreements(), AdobeSign::transientDocuments(), AdobeSign::libraryDocuments(), AdobeSign::webhooks(), AdobeSign::users(), AdobeSign::widgets(), AdobeSign::megaSigns() and AdobeSign::asUser() work through the facade. Forwarded calls use the default client; for a user's own connection, start from forUser().

Core builders in a Laravel app

The builders and resources are the core SDK's; this section shows how they meet Laravel's request, Storage and model layers. For every option, see the core pages on uploading, creating agreements and signing order.

Upload a file from a request

transientDocuments()->upload() accepts a path, an SplFileInfo, an open stream or a PSR-7 stream, and streams the body, so a 20 MB PDF is never held in memory. An UploadedFile lives at a temporary path like /tmp/phpA1b2C3, without an extension, so pass the original name and MIME type explicitly; Adobe uses the name to work out the file type.

$request->validate(['contract' => ['required', 'file', 'mimes:pdf', 'max:25600']]);

$file = $request->file('contract');
$documentId = $sign->transientDocuments()->upload(
    $file->getRealPath(),
    fileName: $file->getClientOriginalName(),
    mimeType: 'application/pdf',
);

A transient document is a short-lived upload. Use the id to create an agreement soon after, typically in the same request or job.

Upload from a Storage disk

For a PDF you generated earlier or keep on S3, pass a read stream. With a stream the SDK can't guess a name, so fileName is required:

use Illuminate\Support\Facades\Storage;

$stream = Storage::disk('s3')->readStream("contracts/{$contract->id}.pdf");

$documentId = $sign->transientDocuments()->upload(
    $stream,
    fileName: "{$contract->reference}.pdf",
    mimeType: 'application/pdf',
);

Create and send an agreement

Creating an agreement sends it: the default state is IN_PROCESS, so the first signer gets an email immediately. Signers who share an order() number sign in parallel; numbers must run 1, 2, 3 without gaps, which the builder checks before anything is sent.

app/Actions/SendContractForSignature.php
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantInfo, ParticipantSetInfo};

final class SendContractForSignature
{
    public function __construct(private readonly AdobeSign $sign)
    {
    }

    public function __invoke(Contract $contract, string $documentId): string
    {
        $agreementId = $this->sign->agreements()->create(
            AgreementInfo::create($contract->title)
                ->file(FileInfo::transientDocument($documentId))
                ->externalId((string) $contract->id)          // find it again with agreements()->all(externalId: ...)
                ->participantSet(ParticipantSetInfo::signer(
                    ParticipantInfo::email($contract->client_email, $contract->client_name),
                )->order(1))
                ->participantSet(ParticipantSetInfo::signer('cfo@example.com')->order(2))
                ->participantSet(ParticipantSetInfo::signer('legal@example.com')->order(2))
                ->message('Please review and sign.'),
        );

        $contract->update(['adobe_agreement_id' => $agreementId, 'status' => 'sent']);

        return $agreementId;
    }
}

Mixed signing orders are an account setting

One signer, then two in parallel, as above, is "hybrid routing". If it is off on the Acrobat Sign account, Adobe answers 403 HYBRID_ROUTING_NOT_ALLOWED and the SDK throws AccountCapabilityException. Purely sequential or purely parallel orders always work.

Track progress

Webhooks are the efficient way to follow an agreement (see Webhooks), but you can also ask:

$agreements = AdobeSign::agreements();

$agreements->status($id);                        // "OUT_FOR_SIGNATURE", "SIGNED", ...
$agreements->get($id)->isSigned();               // bool
$agreements->members($id, includeNextParticipantSet: true);   // raw array: who acted, whose turn it is
$agreements->signingUrlFor($id, 'client@example.com');         // embedded-signing link, or null if not their turn
$agreements->sendReminder($id, note: 'A gentle reminder.');    // reminds whoever's turn it is
$agreements->cancel($id, comment: 'Replaced by a new version.');

Right after creation Adobe is still processing the PDF: the status is DOCUMENTS_NOT_YET_PROCESSED for a few seconds and signingUrlFor() can throw NotFoundException (AGREEMENT_NOT_SIGNABLE). Answer with "try again shortly" rather than an error page, as the example controller does with a 409.

Stream the PDF to the browser

Downloads come back as PSR-7 streams. Copy them into a StreamedResponse in chunks:

use Symfony\Component\HttpFoundation\StreamedResponse;

public function download(string $agreementId, AdobeSign $sign): StreamedResponse
{
    $pdf = $sign->agreements()->combinedDocument($agreementId);

    return new StreamedResponse(function () use ($pdf) {
        while (! $pdf->eof()) {
            echo $pdf->read(65536);
        }
    }, 200, [
        'Content-Type' => 'application/pdf',
        'Content-Disposition' => 'attachment; filename="'.$agreementId.'.pdf"',
    ]);
}

To keep a copy, write it to a disk instead; the walkthrough's download job shows Storage::writeStream().

Turn SDK exceptions into HTTP responses

Every SDK exception implements Conceptlz\AdobeSign\Exception\AdobeSignException, and API errors carry $statusCode and Adobe's $errorCode. Map the ones users can do something about in bootstrap/app.php:

bootstrap/app.php (excerpt)
use Conceptlz\AdobeSign\Exception\{AccountCapabilityException, NotFoundException, OAuthException};
use Illuminate\Foundation\Configuration\Exceptions;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(fn (OAuthException $e) => redirect()
        ->route('adobe-sign.oauth.redirect')
        ->with('status', 'Please reconnect your Acrobat Sign account.'));

    $exceptions->render(fn (AccountCapabilityException $e) => response()->json([
        'message' => 'Your Acrobat Sign plan does not allow this: '.$e->errorCode,
    ], 422));

    $exceptions->render(fn (NotFoundException $e) => response()->json([
        'message' => 'Agreement not found or not ready yet.',
    ], 404));
})

The core's errors page lists every exception class and when it is thrown.

Acting as another user: asUser()

With an account admin's Integration Key you can act on behalf of any user in the account. The SDK sends the x-api-user header; Adobe then treats that user as the sender, so the agreement appears in their Acrobat Sign account and emails come from them.

// Bare emails and ids are prefixed for you; "email:..." and "userid:..." are passed through.
$sales = AdobeSign::asUser('jane@example.com');

$sales->agreements()->create($info);
foreach ($sales->agreements()->all() as $summary) {
    // Jane's agreements
}

asUser() returns a new client; the original is unchanged, so it is safe to call from a shared client. It needs an admin-level key or token; when Adobe rejects the x-api-user header the SDK throws AuthenticationException. If each user has their own OAuth connection, prefer forUser(), which needs no admin rights.

Using the client in queued jobs

Anything slow (downloads, bulk sends, polling) belongs on a queue. Three rules keep jobs correct:

  1. Serialise ids, not clients

    A client holds an HTTP pipeline and a token provider, which don't belong in a queue payload. Store the agreement id (and for OAuth, the user key) on the job, and resolve the client in handle().

  2. For OAuth, name the user explicitly

    There is no signed-in user in a worker, so the default client fails in oauth mode. Pass the user key and call forUser().

  3. Let the queue handle throttling

    The SDK already retries short Retry-After waits. For longer ones it throws RateLimitException with $retryAfter in seconds; release the job for that long instead of failing it.

app/Jobs/SendReminderForUser.php
use Conceptlz\AdobeSign\Exception\RateLimitException;
use Conceptlz\AdobeSign\Laravel\AdobeSignManager;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

final class SendReminderForUser implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public function __construct(
        public readonly int $userId,
        public readonly string $agreementId,
    ) {
    }

    public function handle(AdobeSignManager $adobe): void
    {
        try {
            $adobe->forUser($this->userId)
                ->agreements()
                ->sendReminder($this->agreementId, note: 'Just checking in on this one.');
        } catch (RateLimitException $e) {
            $this->release($e->retryAfter ?? 60);
        }
    }
}

Each job gets a fresh manager, so tokens refreshed in one job are re-read from the database by the next. Adobe doesn't rotate refresh tokens, so two workers refreshing the same user at the same moment both end up with valid access tokens; no locking is needed.

Common questions

Should I use the facade or dependency injection?

Either; they reach the same client. Injection of Conceptlz\AdobeSign\AdobeSign makes dependencies explicit and is easier to replace in unit tests. The facade is shorter in controllers and Tinker, and can be mocked with AdobeSign::shouldReceive() for the manager's own methods.

Why does PHP complain that AdobeSign is already in use?

The facade (Conceptlz\AdobeSign\Laravel\Facades\AdobeSign) and the core client (Conceptlz\AdobeSign\AdobeSign) have the same short name. Import one with an alias, e.g. use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign as Sign;.

Can I pass a user id to forUser() instead of a model?

Yes. Strings and integers are used as the token key as-is. If you set a custom resolver with resolveUserKeyUsing(), pass the key it produces (e.g. 'team:12'), not the raw user id.

Does the configured signature level apply to raw arrays passed to create()?

No. The default is applied when create() receives an AgreementInfo builder. A raw array or generated model is sent exactly as given, so set isDigitalSignatureRequired yourself there.

The builder lacks a field I need. What now?

AgreementInfo, ParticipantSetInfo, ParticipantInfo and WebhookInfo all have with(array $fields), which merges raw API fields into the request. The core's models page explains how to reach every field of a response through $raw or the generated models.