Adobe Sign for Laravelconceptlz/adobe-sign-laravel GitHub

Guides

Walkthrough: send & store

Build the complete loop a contract takes through your app: a form uploads the PDF, a controller sends it in signing order, Adobe calls your webhook when everyone has signed, and a queued job saves the signed PDF to your storage. Every class is complete and copy-pasteable.

The loop at a glance

The code below comes from the package's examples/ directory. It isn't just illustration: the package's test suite (SigningLifecycleTest) runs these exact classes end to end against a fake Acrobat Sign, from the form upload to the PDF landing in storage.

1 · Sending Upload form POST /agreements Controller AgreementController Acrobat Sign sends emails Signers in order 1, 2, … sign 2 · Completion POST AGREEMENT_WORKFLOW_COMPLETED GET status, PDF Webhook route verify, echo, dedupe Queued listener ProcessAdobe… SignWebhook Queued job DownloadSigned… Agreement Storage disk agreements/{id}.pdf event dispatch stream
The web request only sends. Everything after signing happens off the request path: the package acknowledges Adobe within its 5-second limit, and your queue does the downloading.

The split matters. Adobe gives a webhook 5 seconds to answer, and a signed PDF with its audit report can take longer than that to download. So the webhook route only acknowledges and fires an event, the listener runs on the queue, and the heavy work happens in a job that can retry on its own schedule.

Before you start

  • The package is installed and php artisan adobe-sign:verify succeeds (Installation).
  • APP_URL is a public HTTPS address. Locally that means a tunnel; see local development. Adobe only calls HTTPS URLs, and WebhookInfo rejects anything else before sending.
  • ADOBE_SIGN_WEBHOOK_CLIENT_IDS (or ADOBE_SIGN_CLIENT_ID) holds the client id Adobe will send, so the route accepts the callbacks.
  • A queue connection other than sync, and a worker running: php artisan queue:work.
  • A user to sign as. In a developer account, send to addresses you control; Adobe marks the agreements as demo documents.

Step 1: Routes

Your app needs routes for its own controller. The package registers the webhook route (adobe-sign.webhook) itself, so it doesn't appear here.

routes/web.php
use App\Http\Controllers\AgreementController;
use Illuminate\Support\Facades\Route;

Route::middleware(['web', 'auth'])->group(function () {
    Route::post('/agreements', [AgreementController::class, 'store'])->name('agreements.store');
    Route::get('/agreements/{agreementId}', [AgreementController::class, 'show'])->name('agreements.show');
    Route::get('/agreements/{agreementId}/sign', [AgreementController::class, 'signingUrl'])->name('agreements.sign');
    Route::get('/agreements/{agreementId}/pdf', [AgreementController::class, 'download'])->name('agreements.pdf');
});

In a standard app routes/web.php already has the web group; listing it explicitly does no harm and matches the example file.

Step 2: The upload form

A plain multipart form: the PDF, a name, the signers in signing order, and a way to mark a signer as signing at the same step as the one before them.

resources/views/agreements/create.blade.php
<form method="POST" action="{{ route('agreements.store') }}" enctype="multipart/form-data">
    @csrf
    <input type="file" name="contract" accept="application/pdf" required>
    <input type="text" name="name" value="Master Services Agreement" required>

    {{-- Signing order: one row per signer, top to bottom --}}
    <input type="email" name="signers[]" value="jane@client.example.com">
    <input type="email" name="signers[]" value="legal@example.com">
    <label><input type="checkbox" name="parallel[]" value="legal@example.com"> signs together with the signer above</label>
    <input type="email" name="signers[]" value="ceo@example.com">

    <button type="submit">Send for signature</button>
</form>

With the box ticked, Jane and legal sign in parallel at step 1 and the CEO signs at step 2.

Step 3: The controller

The controller validates the request, uploads the PDF, builds the signing order, creates the agreement and registers a webhook for it. It receives the client by injection, so it works with whichever auth mode you configured.

app/Http/Controllers/AgreementController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Builder\AgreementInfo;
use Conceptlz\AdobeSign\Builder\FileInfo;
use Conceptlz\AdobeSign\Builder\ParticipantSetInfo;
use Conceptlz\AdobeSign\Builder\WebhookInfo;
use Conceptlz\AdobeSign\Exception\NotFoundException;
use Conceptlz\AdobeSign\Webhook\EventType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

final class AgreementController
{
    /**
     * POST /agreements: multipart form with `contract` (PDF), `name`, and `signers[]` in signing order.
     * Signers listed in `parallel[]` sign at the same step as the signer before them.
     */
    public function store(Request $request, AdobeSign $sign): JsonResponse
    {
        $data = $request->validate([
            'contract' => ['required', 'file', 'mimes:pdf', 'max:25600'],
            'name' => ['required', 'string', 'max:255'],
            'signers' => ['required', 'array', 'min:1'],
            'signers.*' => ['required', 'email'],
            'parallel' => ['sometimes', 'array'],
            'parallel.*' => ['email'],
        ]);

        // 1. Upload the PDF (streamed, never fully loaded into memory).
        $file = $request->file('contract');
        $documentId = $sign->transientDocuments()->upload(
            $file->getRealPath(),
            fileName: $file->getClientOriginalName(),
            mimeType: 'application/pdf',
        );

        // 2. Build the signing order: each signer is the next step, unless marked parallel.
        //    Mixing sequential and parallel steps needs "hybrid routing" enabled on the Acrobat Sign
        //    account; otherwise Adobe answers 403 HYBRID_ROUTING_NOT_ALLOWED.
        $agreement = AgreementInfo::create($data['name'])
            ->file(FileInfo::transientDocument($documentId))
            ->message('Please review and sign.');

        $parallel = $data['parallel'] ?? [];
        $order = 0;
        foreach ($data['signers'] as $email) {
            $order = in_array($email, $parallel, true) && $order > 0 ? $order : $order + 1;
            $agreement->participantSet(ParticipantSetInfo::signer($email)->order($order));
        }

        // 3. Create it in IN_PROCESS state (the default) = sent to the first signer(s) now.
        $agreementId = $sign->agreements()->create($agreement);

        // 4. Ask Adobe to notify our webhook endpoint about this agreement (one webhook per
        //    agreement is Adobe's recommendation). The package registers the route.
        $sign->webhooks()->create(
            WebhookInfo::forAgreement('Agreement ' . $agreementId, route('adobe-sign.webhook'), $agreementId)
                ->events(EventType::AGREEMENT_ALL),
        );

        return new JsonResponse(['agreement_id' => $agreementId], 201);
    }

    /**
     * GET /agreements/{agreementId}: current status, e.g. OUT_FOR_SIGNATURE or SIGNED.
     */
    public function show(string $agreementId, AdobeSign $sign): JsonResponse
    {
        $agreement = $sign->agreements()->get($agreementId);

        return new JsonResponse([
            'id' => $agreement->id,
            'name' => $agreement->name,
            'status' => $agreement->status,
        ]);
    }

    /**
     * GET /agreements/{agreementId}/sign: the signer's e-sign page (embedded signing).
     */
    public function signingUrl(Request $request, string $agreementId, AdobeSign $sign): JsonResponse
    {
        $user = $request->user() ?? abort(401);

        try {
            $url = $sign->agreements()->signingUrlFor($agreementId, $user->email);
        } catch (NotFoundException $e) {
            // Right after creation Adobe may still be processing the PDF (AGREEMENT_NOT_SIGNABLE).
            return new JsonResponse(['message' => 'Not ready for signing yet, try again shortly.'], 409);
        }

        return $url === null
            ? new JsonResponse(['message' => "It isn't your turn to sign."], 403)
            : new JsonResponse(['url' => $url]);
    }

    /**
     * GET /agreements/{agreementId}/pdf: stream the current PDF straight from Adobe to the browser.
     */
    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"',
        ]);
    }
}

What each part of store() does

  1. Validation before anything leaves your app

    mimes:pdf and max:25600 (25 MB) reject bad uploads with a normal 422, and no request reaches Adobe. The package's test suite checks exactly that.

  2. Upload as a transient document

    The upload is streamed through a temporary file, so memory use stays flat for large PDFs. The original file name and MIME type are passed because the temporary path has no extension.

  3. Turn the form into a signing order

    The loop gives each signer the next order number, unless they appear in parallel[], in which case they reuse the current number. With the form above the result is Jane 1, legal 1, CEO 2. The builder checks that the numbers run 1..n without gaps before sending anything.

  4. Create, which also sends

    agreements()->create() returns the new agreement id. Its default state, IN_PROCESS, means Adobe emails the first step's signers immediately. The project's signature level from ADOBE_SIGN_SIGNATURE_LEVEL is applied here too.

  5. Register a webhook for this agreement

    route('adobe-sign.webhook') builds the public URL of the package's route from APP_URL or the current request. Adobe verifies that URL immediately by calling it, and the package answers the handshake, so this call only succeeds when your app is reachable. One webhook per agreement is Adobe's recommendation: if deliveries keep failing, Adobe disables only that webhook.

Keep a record on your side

The example returns the agreement id as JSON. In a real app, store it on your own model (say contracts.adobe_agreement_id) and set ->externalId((string) $contract->id) on the builder, so both systems can find each other. See Create and send an agreement.

Step 4: The webhook route (already done)

You don't write this part. When the last signer finishes, Adobe sends a POST to /adobe-sign/webhook with an X-AdobeSign-ClientId header and a JSON body whose event is AGREEMENT_WORKFLOW_COMPLETED. The package's WebhookController:

  1. rejects the call with 403 if the client id isn't on your allow-list;
  2. parses the body into a typed event (AgreementEvent here), or answers 400 if it can't;
  3. adds the notification id to the cache, and only fires WebhookReceived if it wasn't there already;
  4. answers 200 with the client id echoed in a header and the JSON body, which is how Adobe knows the delivery succeeded.

All of that takes milliseconds, well inside Adobe's 5 seconds, provided your listeners are queued. Details on the Webhooks page.

Step 5: The queued listener

The listener decides what each event means for your app. It implements ShouldQueue, so Laravel serialises the event onto the queue and the webhook request returns straight away.

app/Listeners/ProcessAdobeSignWebhook.php
<?php

declare(strict_types=1);

namespace App\Listeners;

use App\Jobs\DownloadSignedAgreement;
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Conceptlz\AdobeSign\Webhook\EventType;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;

final class ProcessAdobeSignWebhook implements ShouldQueue
{
    public function handle(WebhookReceived $received): void
    {
        $event = $received->event;

        if (!$event instanceof AgreementEvent) {
            return; // web form, Send in Bulk or library events: handle them here if you subscribe to them
        }

        Log::info('Acrobat Sign: ' . $event->event, [
            'agreement' => $event->agreementId(),
            'by' => $event->actingUserEmail,
        ]);

        match (true) {
            // Every participant has acted: fetch and keep the signed PDF.
            $event->isWorkflowCompleted() => DownloadSignedAgreement::dispatch((string) $event->agreementId()),

            // One signer finished; others may still be pending.
            $event->is(EventType::AGREEMENT_ACTION_COMPLETED) => null,

            // Declined or cancelled: update your records / notify someone.
            $event->is(EventType::AGREEMENT_REJECTED, EventType::AGREEMENT_RECALLED, EventType::AGREEMENT_EXPIRED) => null,

            default => null,
        };
    }
}

Laravel 11 and later discover listeners in app/Listeners from the type-hint on handle(). If you turned discovery off, register it in a service provider:

use App\Listeners\ProcessAdobeSignWebhook;
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Illuminate\Support\Facades\Event;

Event::listen(WebhookReceived::class, ProcessAdobeSignWebhook::class);

AGREEMENT_ALL subscribes to every agreement event, so the listener also sees views, reminders and individual signatures. The match makes the interesting ones explicit and ignores the rest. The null arms are where you'd update your own records, e.g. mark the contract as declined.

Step 6: The download job

The job does the slow, fallible part, with its own retry budget. It is the only place that touches Adobe after signing.

app/Jobs/DownloadSignedAgreement.php
<?php

declare(strict_types=1);

namespace App\Jobs;

use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Exception\RateLimitException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Storage;

final class DownloadSignedAgreement implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;

    public int $tries = 5;

    public function __construct(public readonly string $agreementId)
    {
    }

    public function handle(AdobeSign $sign): void
    {
        try {
            // Webhook payloads aren't signed, so confirm the status with the API before acting on it.
            $agreement = $sign->agreements()->get($this->agreementId);
            if (!$agreement->isSigned()) {
                return;
            }

            // A PSR-7 stream: written to storage in chunks, never held in memory as a whole.
            $pdf = $sign->agreements()->combinedDocument($this->agreementId, attachAuditReport: true);
        } catch (RateLimitException $e) {
            $this->release($e->retryAfter ?? 60);

            return;
        }

        $resource = $pdf->detach() ?? throw new \RuntimeException('Download stream is not readable.');

        Storage::disk(config('filesystems.default'))->writeStream("agreements/{$this->agreementId}.pdf", $resource);
    }
}

Why the job is written this way

  • It re-checks the status. Adobe doesn't sign webhook payloads. The client-id allow-list stops casual forgeries, but the safe pattern is to treat the webhook as a hint and ask the API for the truth. Only a real SIGNED status leads to a download.
  • It asks for the audit report. attachAuditReport: true appends Adobe's audit trail (who signed, when, from which IP, with which verification) to the PDF. Keep it with the document: it is what you show when a signature is challenged.
  • It streams. combinedDocument() returns a PSR-7 stream; detach() hands its underlying PHP resource to Storage::writeStream(), which copies it to local disk, S3 or any other driver in chunks.
  • It cooperates with throttling. Short Retry-After waits are retried inside the SDK. A long one surfaces as RateLimitException, and the job puts itself back on the queue for that many seconds instead of burning a try.
  • It injects the default client. That is right for an Integration Key. With OAuth there is no signed-in user in a worker, so pass the user key into the job and use AdobeSignManager::forUser(); see queued jobs.

Step 7: Try it

  1. Start a worker
    php artisan queue:work
  2. Submit the form

    Signed in to your app, upload a PDF with two or three addresses you control. The response contains the agreement id, and the first signers receive an email from Acrobat Sign.

  3. Watch progress

    Open /agreements/{id} to see the status move from OUT_FOR_SIGNATURE to SIGNED. Each signature also produces an AGREEMENT_ACTION_COMPLETED line in your log from the listener.

  4. Find the signed PDF

    After the last signature, the worker runs the listener, then the job, and storage/app/agreements/{id}.pdf (or the same path on your default disk) appears, with the audit report on its last pages.

Step 8: Test it without Adobe

The package's own SigningLifecycleTest runs this whole loop with a fake HTTP client and the sync queue: it posts the form, checks the upload body, the signing order and the webhook URL, posts Adobe's completion webhook to the route, and asserts the PDF is in the faked disk. The Testing page shows how to write the same test in your app.

Hardening for production

  • Idempotency. The package drops duplicate deliveries for 72 hours, but a job can still run twice (a retry after a timeout, a manual replay). Writing the same file twice is harmless here; if the job also sends emails or bills, check your own record first or use ShouldBeUnique keyed on the agreement id.
  • Failure handling. Add a failed() method to the job that flags the contract for attention. After 72 hours of failed webhook deliveries Adobe disables the webhook, so a scheduled sweep that checks agreements still marked "sent" is a cheap safety net.
  • Timeouts. Large agreements with many pages can take a while to download. Give the job a $timeout that fits, and make sure your HTTP client timeout (see the HTTP client) is shorter than the job timeout.
  • Storage layout. Store under a path you control (agreements/{year}/{id}.pdf) and keep the agreement id on your model. Adobe also keeps the document, but your retention rules may differ from theirs.
  • Signature level. If the project requires AES or QES, check it in the job before marking the contract complete: AdobeSign::signatureShortfalls($this->agreementId). See checking after signing.

Common questions

webhooks()->create() fails while creating the agreement works. Why?

Adobe calls your URL to verify it before creating the webhook. It must be public HTTPS, reach the package route without auth or CSRF middleware, and your allow-list must contain the client id Adobe sends. Locally you need a tunnel. See Troubleshooting.

Everyone signed, but no PDF appeared in storage. Where do I look?

Check, in order: that a queue worker is running (the listener and job are both queued), the failed_jobs table, your log for "Rejected Acrobat Sign webhook" warnings, and the webhook's status in Acrobat Sign. More in Troubleshooting.

Can I use one account-wide webhook instead of one per agreement?

Yes: create it once with WebhookInfo::forAccount('All agreements', route('adobe-sign.webhook')) (an admin task) and drop step 4 of the controller. Adobe recommends per-agreement webhooks because a disabled webhook then affects one agreement, not all of them.

How does this change if users send from their own accounts via OAuth?

In the controller, use AdobeSign::forUser($request->user()) instead of the injected client. In the listener, look up which user owns the agreement (from your own records) and pass that user's key into the job, which calls forUser($key). The webhook route itself needs no change. See OAuth.

Can I store the audit report as a separate file?

Yes. Call combinedDocument($id) without attachAuditReport for the signed PDF and auditTrail($id) for the report, and write each stream to its own path. The core's downloads page lists the other formats.