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.
Requires
- php: >=8.4.1 <8.6
- composer-runtime-api: ^2.2
- ext-fileinfo: *
- ext-gd: *
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- ext-sodium: *
- ext-zlib: *
- intervention/image: ^4.3
- psr/log: ^3.0
- symfony/console: ^8
- symfony/http-client: ^8
- symfony/http-client-contracts: ^3.6
- symfony/process: ^8
- symfony/uid: ^8
- tecnickcom/tc-lib-pdf-sign: ^2.0
Requires (Dev)
- ext-tokenizer: *
- ergebnis/composer-normalize: ^2.52
- laravel/pint: ^1.30
- pestphp/pest: ^5.0
- pestphp/pest-plugin-type-coverage: ^5.0
- phpstan/phpstan: ^2.2
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- shipmonk/composer-dependency-analyser: ^1.8
Suggests
- ext-gd: To use GD library based image processing.
- ext-imagick: To use Imagick based image processing.
- lsnepomuceno/laravel-a1-pdf-sign: A Laravel package sharing this one lineage, being rebuilt on top of it.
Provides
None
Conflicts
None
Replaces
None
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.
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-signwraps 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.