Search by

lsnepomuceno / signet-pdf

Sign and verify PDF signatures in PHP. Each signature is appended as a new revision, so signing again never invalidates the ones before it. PAdES B-B to B-LTA, RFC 3161 timestamps, LTV and ICP-Brasil identity. No framework.

3.0.0 2026-09-02 08:14 UTC

This package is auto-updated.

Last update: 2026-09-02 14:04:56 UTC


README

Sign and verify PDF signatures in PHP, from PKCS#12 or PEM, with PAdES profiles,
long-term validation and cryptographic verification. No framework.

Latest version Downloads Tests License

PHP PAdES PHPStan Type coverage

Documentation  ·  Getting started  ·  Public API  ·  Upgrading  ·  Changelog  ·  Signed samples

Installation

composer require lsnepomuceno/signet-pdf

There is nothing to register: no service provider, no facade, no container and no global state. src/Signet.php wires the default object graph by hand, and every class can also be built directly, which is what lets an application register them in its own container instead.

openssl on PATH is not required to sign. It is used only for verifying a signature and for reading a legacy PFX file, and vendor/bin/signet check reports what the environment can actually do before anything is signed.

Signing

use LSNepomuceno\Signet\Signet;
use LSNepomuceno\Signet\Enums\SignatureProfile;

$signed = new Signet()->newSignature()
    ->certificate('/path/certificate.pfx', $password)
    ->pdf('/path/contract.pdf')
    ->info(name: 'Lucas Nepomuceno', reason: 'Contract')
    ->profile(SignatureProfile::PadesBB)
    ->sign();

$signed->save('/path/contract-signed.pdf');

A revision is appended, never a rebuilt document, so the original bytes survive and an earlier signature stays valid. That is what keeps annotations, form fields and every previous signature intact, and it is the single most important behaviour in the package.

Verifying

$report = new Signet()->validate('/path/contract-signed.pdf');

$report->isValid();      // every signature verifies against the bytes it covers
$report->count();        // how many signatures the document carries
$report->signers();      // structured signer identity
$report->findings();     // everything else the validator established

isValid() means the CMS actually verifies, not that a subject line could be parsed. Validation makes no network request and cannot be made to: revocation is evaluated from the material the document itself carries. Whether to accept the signer is a separate question, answered against roots you name.

Entry points

The builder above is the primary API. For callers that do not need it, Signet offers one-shot entry points, and each is covered in the guide:

signFromFile() sign with a PKCS#12 bundle on disk
signFromPem() sign with PEM, key combined or separate
validate() a Data\SignatureReport for a document
signatureFields() the signature fields a document declares, signed or not
addSignatureField() lay out an empty field, so a template can be prepared here rather than in a word processor
complete() finish a signature prepare() set up, with a CMS made somewhere else
extendArchive() a further archive timestamp, with no certificate involved
encryptCertificate() seal a bundle and its password at rest
decryptCertificate() open what was sealed
vault() the encrypter behind both, to bring your own scheme
icpBrasil() conformance of a Brazilian certificate against its own specification

It is a convenience over the parts, never a layer in front of them. Nothing in src/ depends on it, every class it builds can be built directly, and an application with its own container should register those classes and ignore the entry point entirely.

What it does

PKCS#12 and PEM .pfx, .p12, or a PEM certificate with the key beside it or in its own file
Incremental signing a revision is appended, never a rebuilt document, so earlier signatures and form fields survive
PAdES profiles legacy through pades-b-lta, with RFC 3161 timestamps and long-term validation
Visible seals rendered from the certificate or drawn from your own artwork, on the page you name
Template fields fills a signature field a contract already carries, instead of appending beside it
Certification ISO 32000-1 §12.8.2.2 DocMDP, plus field locks that later signatures honour
Encrypted documents AES-128 and AES-256, signed and re-encrypted under the document's own key
Two-phase signing prepare() here, sign on a token, an HSM or a cloud service, complete() here: the private key never has to be in this process
Large documents signing needs a little more than the size of the document, not a multiple of it: a 300 MB file signs in 310 MB
A signing receipt what was signed, at what profile, when, by whom, with the digest of the document before and after
Archive maintenance refresh a B-LTA archive with no certificate and no key material involved, from PHP or from a cron entry
Verification the CMS is actually verified, with the timestamp, the profile and revocation reported
ICP-Brasil identity CPF, CNPJ (including the alphanumeric one) and the rest, read from the certificate rather than parsed out of a name
ICP-Brasil policies a signature declares the policy it was made under, and an archival one carries the policy document and ITI's published list inside the file
PDF/A a signed document stays conformant, measured with veraPDF rather than assumed
PDF/UA measured too: an accessible document stays conformant, seal or not, because the widget joins the structure tree and carries a description

Documentation

Everything else is at lsnepomuceno.github.io/signet-pdf: twenty-one pages covering profiles and timestamps, visible seals, signature fields, certification, encrypted documents, validation, trust, ICP-Brasil, the command line, testing your own code, every exception this package raises and every limit it still has, plus the changelog and the upgrade guide. The earlier lines are archived at /v2/ and /v1/.

Getting started  ·  Public API  ·  Troubleshooting  ·  Why the design is what it is

Compatibility

Package PHP Notes
^3 8.4.1 – 8.5 the current line, and what this page documents
^2 8.4.1 – 8.5 the previous line; UPGRADE.md is the path across
^1 8.4.1 – 8.5 archived; upgrading crosses 2.x first

ext-openssl, ext-sodium, ext-gd, ext-mbstring, ext-zlib, ext-fileinfo and ext-json are required. The openssl binary on PATH is needed only by the legacy certificate reader and by the default signature verifier, which can be swapped for one that starts no process.

The floor is 8.4.1 rather than 8.4 because symfony/process 8.1.0 requires it, so a platform of 8.4.0 cannot resolve this package at all (docs/decisions/0005-php-and-laravel-floor.md).

Using Laravel? lsnepomuceno/laravel-a1-pdf-sign wraps a signing core with a service provider, a facade and Artisan commands. The two are separate implementations sharing a lineage rather than a core and an integration; UPGRADE.md maps the surface across.

Verified, not asserted

Signed output is checked against tools that were not written here, because a validator sharing its assumptions with the signer proves very little: veraPDF decides PDF/A and PDF/UA, pyHanko enforces /DocMDP, qpdf checks structure, the Arlington PDF Model checks the emitted objects against the specification's own grammar, EU DSS recomputes the digest a signature declares for its policy and reads the PAdES baseline level the output reaches, and poppler's pdfsig has caught defects the suite passed straight through.

The last one earned its place by seeing what the others could not. Every ICP-Brasil policy digest this package shipped was the hash of the wrong artefact, and pdfsig, pyHanko and Demoiselle all reported the resulting document as valid, correctly: none of them resolves the policy document, so none of them ever compares.

samples/ holds one signed document per profile plus the awkward cases, indexed and explained in Sample documents. Which test exercises which tool, and why, is in Standards and instruments.

Contributing

Patches are expected to come with tests. composer check runs everything CI runs: Pint, PHPStan at level max with no baseline, a dependency report, the manifest normaliser and the suite. Everything runs in Docker, because the floor is PHP 8.4.

docker compose -f .docker/compose.yaml run --rm php composer check

See CONTRIBUTING.md.

Security

Found a vulnerability? Please follow SECURITY.md rather than opening a public issue.

Licence

MIT. See LICENSE.md.