Adobe Sign for Laravelconceptlz/adobe-sign-laravel GitHub

Guides

Signature levels

Some contracts are fine with a typed name; others need a certificate-based or even a qualified signature. This page shows how a Laravel project sets its level in .env, overrides it where needed, and proves afterwards that every signer reached it.

The three levels in brief

The SDK uses the vocabulary of the EU's eIDAS Regulation, which many other jurisdictions mirror. The short version:

SES

Simple electronic signature

Any electronic sign of agreement: a typed name, a drawn signature, a click. Acrobat Sign's default. Adobe seals the finished PDF with its own certificate and keeps an audit trail. Enough for most commercial contracts.

AES

Advanced electronic signature

Uniquely linked to and identifying the signer, under their sole control, and detecting later changes. In Acrobat Sign: a certificate-based digital signature from a cloud signature provider, with the signer's own certificate embedded in the PDF.

QES

Qualified electronic signature

An AES made with a qualified certificate from a qualified trust service provider (QTSP) on a qualified device. Legally equivalent to a handwritten signature across the EU. The API request is the same as AES; the provider makes it qualified.

Which level a document needs is a legal question for you and your counsel, not a technical one. The core documentation's signature levels page explains the legal background, which documents typically need what, and which providers exist per jurisdiction.

Set the project's default level

.env
ADOBE_SIGN_SIGNATURE_LEVEL=aes        # ses (default), aes or qes

The manager turns this into ClientOptions::$defaultSignatureLevel on every client it builds, the Integration Key client and forUser() clients alike. From then on, every agreement created from an AgreementInfo builder asks Adobe for that level on each signer set that doesn't choose its own. For AES and QES that means isDigitalSignatureRequired: true on the signer's participant set, so Adobe makes the signer pick a cloud signature provider and sign with a certificate.

Read the configured value anywhere:

use Conceptlz\AdobeSign\Enum\SignatureLevel;

AdobeSign::signatureLevel();                                   // SignatureLevel::AES
AdobeSign::signatureLevel()->requiresDigitalSignature();       // true
AdobeSign::signatureLevel()->satisfies(SignatureLevel::SES);   // true: AES is at least as strong

A value other than ses, aes or qes (in any case) makes client creation throw MisconfiguredException, so a typo fails loudly instead of quietly sending SES.

What the Acrobat Sign account needs

  • SES works on every account.
  • AES needs digital signatures enabled in Acrobat Sign admin, with at least one cloud signature provider available to signers.
  • QES additionally needs a qualified trust service provider as that provider, with qualified certificates for your signers' jurisdiction. Without one, signers can only produce an AES, and the check below will say so.

Override per agreement

Different documents can need different levels in one app: an NDA at SES and an employment contract at QES. Set it on the builder and it beats the .env default:

app/Actions/SendEmploymentContract.php
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantSetInfo};
use Conceptlz\AdobeSign\Enum\SignatureLevel;
use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign;

$agreementId = AdobeSign::agreements()->create(
    AgreementInfo::create('Employment contract')
        ->file(FileInfo::transientDocument($documentId))
        ->participantSet(ParticipantSetInfo::signer('new.hire@example.com')->order(1))
        ->participantSet(ParticipantSetInfo::signer('hr.director@example.com')->order(2))
        ->signatureLevel(SignatureLevel::QES),
);

A neat Laravel pattern is to keep the mapping from document type to level in your own config, so compliance can review it in one place:

// config/contracts.php
'signature_levels' => [
    'nda' => 'ses',
    'supplier_agreement' => 'aes',
    'employment_contract' => 'qes',
],

// when sending
$level = SignatureLevel::from(strtoupper(config("contracts.signature_levels.{$contract->type}", 'ses')));
$info->signatureLevel($level);

Override per signer

Levels can also differ between signers of one agreement, in either direction. The participant set's level wins over the agreement's, which wins over the project default:

AgreementInfo::create('Share purchase agreement')
    ->file(FileInfo::transientDocument($documentId))
    ->signatureLevel(SignatureLevel::QES)                                   // buyers and sellers: QES
    ->participantSet(ParticipantSetInfo::signer('buyer@example.com')->order(1))
    ->participantSet(ParticipantSetInfo::signer('seller@example.com')->order(1))
    ->participantSet(ParticipantSetInfo::signer('witness@example.com')->order(2)
        ->signatureLevel(SignatureLevel::SES))                               // the witness: SES is enough
    ->participantSet(ParticipantSetInfo::approver('legal@example.com')->order(3));

Only signers sign, so only signer sets can be held to a level. Asking an approver set for AES or QES throws the core InvalidArgumentException immediately; approvers and other non-signing roles are simply left alone by the agreement-wide level.

The builder also refuses combinations that can't work, before any request:

  • AES or QES with signatureType('WRITTEN') (paper signatures can't be digital);
  • AES or QES on an agreement that has no signer at all.

Builders only

The .env default is applied when agreements()->create() receives an AgreementInfo. If you pass a raw array or a generated AgreementCreationInfo model, it is sent exactly as given; set isDigitalSignatureRequired on each signer set yourself.

Identity checks are a separate dial

Asking a signer to confirm an email code, an SMS code, a government ID or a national eID controls who may open and sign the document. It doesn't change the signature level. You can combine any identity check with any level, with one Adobe restriction: one authentication method per participant.

use Conceptlz\AdobeSign\Builder\{ParticipantInfo, Verification};

ParticipantSetInfo::signer(
    ParticipantInfo::email('new.hire@example.com', 'Sam Example')->verifyWith(Verification::emailOtp()),
)->order(1)->signatureLevel(SignatureLevel::AES);

The core's identity verification page covers every method and the account capabilities they need.

Check what each signer reached

Requesting a level is not the same as getting it. A signer might have used a provider whose certificate isn't qualified, or the account may lack a QTSP. After signing, check the outcome:

AdobeSign::signatureShortfalls(string $agreementId, ?SignatureLevel $required = null): list<string>

Loads the agreement's signature evidence (members and events) and compares every signer with $required, or the configured signature_level when omitted, treating certificates from qualified_issuers as qualified. Returns one sentence per signer who fell short; an empty array means the level was met. Checking QES with an empty issuer list throws the core InvalidArgumentException, because without it nothing could ever count as qualified.

.env
ADOBE_SIGN_SIGNATURE_LEVEL=qes
ADOBE_SIGN_QUALIFIED_ISSUERS="DigiCert,TrustPro QTSP"

Issuers are matched case-insensitively as substrings of the certificate's issuer, so TrustPro QTSP matches "TrustPro QTSP Issuing CA 2". In the download job from the walkthrough, check before marking the contract complete:

$problems = AdobeSign::signatureShortfalls($this->agreementId);

if ($problems !== []) {
    // e.g. "Jane@Client.example signed with an electronic signature (TYPED), not a digital one: SES, QES required."
    //      "ceo@vendor.example has not signed yet."
    $contract->update(['status' => 'needs_review', 'signature_issues' => $problems]);

    return;
}

$contract->update(['status' => 'complete']);

The messages are written for people, so you can show them to an operator or store them as they are. A signer who signed digitally with a non-qualified certificate gets a message naming the issuer, which tells you what to add to qualified_issuers if that provider is in fact qualified.

The full evidence

For more than a yes or no, fetch the evidence object the check is built on:

$evidence = AdobeSign::agreements()->signatureEvidence($agreementId);

$evidence->allSigned();                                                     // bool
$evidence->meets(SignatureLevel::AES);                                      // bool
$evidence->lowestLevel(AdobeSign::qualifiedIssuers());                      // weakest level reached, or null

foreach ($evidence->signers as $signer) {
    $signer->email;                  // in signing order
    $signer->signedAt;               // ISO 8601
    $signer->signatureInputType;     // e.g. TYPED, SCRIBBLE, or a digital type
    $signer->digitallySigned;        // bool
    $signer->certificateIssuer;      // e.g. the QTSP's CA
    $signer->signingProvider;        // cloud signature provider
    $signer->verification;           // identity check: NONE, EMAIL_OTP, PHONE, ...
    $signer->achievedLevel(AdobeSign::qualifiedIssuers());   // SignatureLevel, or null if not signed
}

Storing this per signer alongside your contract gives you a record of how each person signed, independent of Adobe's retention.

Testing level-dependent code

Set the config before the first use in a test, then rebuild the scoped manager so it sees the change:

config(['adobe-sign.signature_level' => 'qes', 'adobe-sign.qualified_issuers' => ['TrustPro QTSP']]);
$this->app->forgetScopedInstances();

// queue fake responses, call your code, then assert on the request body:
$sent = json_decode((string) $this->adobe->getRequests()[1]->getBody(), true);   // [0] is GET /baseUris
$this->assertTrue($sent['participantSetsInfo'][0]['isDigitalSignatureRequired']);

The Testing page shows how to set up the fake HTTP client.

Common questions

If the request for QES is the same as for AES, what does the setting do?

Two things: it requests a digital signature from each signer, exactly like AES, and it makes signatureShortfalls() hold signers to the qualified standard by default. Whether signers can actually produce a QES depends on the signature provider configured on the Acrobat Sign account.

Can one agreement mix SES and QES signers?

Yes. Set the agreement's level and override it on individual signer sets with ParticipantSetInfo::signatureLevel(). When you check afterwards, pass the level you care about, or check signers individually with achievedLevel(), since signatureShortfalls() holds every signer to one level.

How do I find the exact issuer name for qualified_issuers?

Sign a test agreement with your provider, then look at signatureEvidence($id)->signers[0]->certificateIssuer, or read the message signatureShortfalls() returns, which quotes the issuer. Add a distinctive part of it; matching is a case-insensitive substring.

Does requiring a government ID check make a signature AES?

No. Identity checks control who may sign; the signature level is about how the signature itself is made. A signer can pass a government ID check and still sign with a typed name, which is SES. Request AES or QES for a certificate-based signature.

Does changing ADOBE_SIGN_SIGNATURE_LEVEL affect agreements already sent?

No. The level is part of the request that creates the agreement. Only agreements created after the change (and after a config cache refresh) use the new default. signatureShortfalls(), however, uses the current setting unless you pass $required, so pass the level that applied when the agreement was sent if you check old agreements.