Digital Signature for Filament GitHub

#Signatory Routing & Multi-Signatory Documents

Some documents are signed by roles, not by whoever happens to open them. An Accomplishment Report has a Prepared by, an Attested by and a Noted by; each is a person tagged on the record, and each signs in their own right.

This page covers how the package finds those people, brings the document to them, and combines their signatures into one PDF — and exactly what each consent model does and does not permit.

New to the package? Read PDF Templates first. Routing builds directly on slots, the placement designer, and the Signable contract.


#The five-minute version

PHP
// 1. config/signature.php — declare the roles
'templates' => [
    'accomplishment-report' => [
        'label'    => 'Accomplishment Report',
        'view'     => 'pdf.accomplishment-report',
        'signable' => \App\Models\AccomplishmentReport::class,
        'slots'    => [
            'prepared_by' => ['label' => 'Prepared by', 'signatory' => 'preparedBy', 'order' => 1, 'required' => true],
            'attested_by' => ['label' => 'Attested by', 'signatory' => 'attestedBy', 'order' => 2, 'required' => true],
            'noted_by'    => ['label' => 'Noted by',    'signatory' => 'notedBy',    'order' => 3, 'required' => true],
        ],
    ],
],
PHP
// 2. The model — two traits, plus the relations the app already has
class AccomplishmentReport extends Model implements Signable
{
    use HasPdfTemplate, HasSignatories;

    protected string $signaturePdfTemplate = 'accomplishment-report';

    public function preparedBy(): BelongsTo { return $this->belongsTo(User::class, 'prepared_by_id'); }
    public function attestedBy(): BelongsTo { return $this->belongsTo(User::class, 'attested_by_id'); }
    public function notedBy(): BelongsTo    { return $this->belongsTo(User::class, 'noted_by_id'); }
}
PHP
// 3. The resource — one entry, one action
SignatoryPanel::make('signatories');
RequestSignaturesAction::make();
  1. Open /admin/signature-templates/accomplishment-report/design once and drag the three slots onto the signature lines.

That's the whole integration. Each signatory registers their signature once in their own panel; from then on, being tagged on a record is enough for the document to find them.


#How a slot gains a signatory

A slot used to be a named rectangle. It now answers a second question — whose rectangle is it?

PHP
new SlotDefinition(
    key:       'attested_by',
    label:     'Attested by',
    required:  true,
    signatory: 'attestedBy',   // who fills it
    role:      'attester',     // optional: scope for grants and policies
    order:     2,              // optional: signing sequence
);

#Four ways to bind a signatory

FormExampleWhen
Relation name'attestedBy'The record has a belongsTo. The common case.
Foreign key'attested_by_id'The record stores only an id, no relation.
Closurefn ($record) => $record->department->headDerived from something other than a direct column.
Invokable class\App\Signatories\DepartmentHead::classReusable across templates; unit-testable on its own.

An invokable class may implement SignatoryResolver for the richer resolve($record, $slot) signature, or just define __invoke.

A slot with no signatory is unrouted — nobody in particular owns it, and whoever opens the signer page places their own signature. That is the original behaviour, unchanged.

#A binding that can't resolve is not an error

If attested_by_id is null, or the relation's parent has been deleted, routing reports the slot as unassigned rather than throwing. A half-filled record is a normal state in a workflow, and the UI needs to be able to say which role is missing.


#Reading the routing

SignatoryRouter joins four things that are otherwise independent: the template's slots, the designer's saved coordinates, the people tagged on the record, and each person's signature library.

PHP
$routes = app(SignatoryRouter::class)->routeFor($report);
// or, via the trait:
$routes = $report->signatoryRoutes();

$routes['attested_by']->signerName();   // "Maria Santos"
$routes['attested_by']->signature;      // her active primary Signature, or null
$routes['attested_by']->position;       // ['page' => 1, 'x' => 300.0, …]
$routes['attested_by']->state;          // RouteState::AwaitingConsent
$routes['attested_by']->blockerMessage();

Every UI surface and the signing pipeline read this same structure, so the panel and the signer can never disagree about who is blocking a document.

#Slot states

StateMeaningWhat fixes it
unassignedNobody is tagged for this roleTag someone on the record
awaiting_registrationThe tagged person has never registered a signatureThey register one in their own panel
awaiting_consentEverything is ready; waiting on them to signThey sign (or a grant signs for them)
readySignable right now
blockedWaiting on an earlier slot in a sequential sessionThe earlier signatory signs
signedDone, embedded in the document
declinedThe signatory refusedReassign the role, or cancel

awaiting_registration is the useful one: the AR can't be finalised because Dr. Reyes has never uploaded a signature, and the UI says exactly that instead of rendering a blank line.

blocked never replaces unassigned or awaiting_registration — those name a problem somebody has to fix, and "waiting on an earlier signatory" would hide it.

#Useful shortcuts

PHP
$report->signatureBlockers();      // ['noted_by' => 'Dr Reyes has not registered a signature yet.']
$report->isReadyForSignatures();   // false — opening a session now would stall
$report->isFullySigned();
$report->signedDocumentPath();
$report->pendingSignatureRequestFor(auth()->id());

#Signing sessions

A multi-signatory document needs an owner. SigningSession is it.

PHP
$session = $report->openSigningSession();

Opening a session does two things that matter:

It freezes the PDF. The document is rendered once into base_document_path and never re-rendered. Without that, the second signatory would sign a fresh render and erase the first signatory's stamp.

It creates one SignatureRequest per slot, copying the placement in. A later edit in the placement designer cannot retroactively move a signature somebody has already been asked to apply.

Opening is idempotent — calling it again returns the existing open session with assignments refreshed.

#Sequential vs parallel

PHP
'sessions' => ['sequence_mode' => 'sequential'],   // default

Sequential honours SlotDefinition::$order: the Dean cannot sign before the Department Head. A signature out of turn raises OutOfSequenceException. Parallel lets any assigned signatory act at any time. Set it per template with 'sequence_mode' => 'parallel' in the template config.

#Late assignment

A session can open before every role is filled. When the record is tagged later:

PHP
app(SigningSessionManager::class)->refreshAssignments($session);

Requests that already reached a decision are never touched — a signature that happened, happened.

#The signature chain

Each signature is chained to the one before it:

Text
base.pdf ──sig#1(Juan)──▶ signed-1.pdf ──sig#2(Maria)──▶ signed-2.pdf ──sig#3──▶ final.pdf
             │                                │
   document_hash = H(base)          document_hash = H(signed-1)
   signed_document_hash = H(signed-1)         parent_signature_id = sig#1

Signature N's document_hash equals signature N-1's signed_document_hash, and parent_signature_id records the link. The progression is verifiable from the database regardless of what the PDF file itself can carry — which matters, as the next section explains.


#Signing modes — read this before choosing

PHP
'multi_signature' => ['mode' => 'progressive'],   // default

#progressive (default)

Each signature is stamped onto the previous signatory's output and the document is re-signed with that signatory's certificate.

  • ✅ The finished PDF shows every visible signature.
  • ✅ Every signature has its own digital_signatures row, its own certificate fingerprint, its own timestamp, and a verifiable hash chain.
  • ⚠️ Only the most recent PKCS#7 block survives inside the PDF. FPDI and TCPDF rebuild the file on every pass, which necessarily discards the previous signature block. A PDF reader will show one cryptographic signature, not N.

#incremental

True PAdES: each signature is appended as an ISO 32000 incremental update, and every earlier signature stays cryptographically valid — a reader shows N distinct signers with N certificates.

This requires a driver implementing SupportsIncrementalSigning. Neither bundled driver can do it, and that is a property of FPDI rather than an oversight: FPDI re-imports and rewrites the document, which invalidates any existing signature. Writing incremental updates in PHP needs a library built for it (e.g. SetaPDF-Signer).

Setting incremental without such a driver raises IncrementalSigningUnsupportedException at sign time. That is deliberate: the alternative is a document that silently claims three signatures and carries one.

#Choosing

Your requirementMode
A visible signature block, tamper-evidence, and a full audit trailprogressive
N independently verifiable signer certificates in the PDF itselfincremental + a capable driver

If the answer is legal rather than technical, get it confirmed before building on progressive.


Automation can remove the effort of signing. It must never remove the consent. Everything below exists to keep those two apart.

PHP
'auto_affix' => ['mode' => 'approval'],   // default

#approval — the default

Never auto-signs. Routing finds the right person, the placement is pre-filled, and the document appears in their inbox — but the PKCS#7 block is produced in their own authenticated request, with their own certificate.

The automation is real: nobody hunts for the document, nobody re-draws a signature, nobody types coordinates. What remains is one click by the person whose name is on the line. No new trust assumptions over single-signer signing.

#delegated — standing authorisation

The signatory opts in once, from their own account:

☑ Auto-apply my signature when I am tagged as Attested by on an Accomplishment Report — until 2027-01-01.

PHP
app(AutoAffixService::class)->grant(
    grantorId:   auth()->id(),
    signature:   $mySignature,
    templateKey: 'accomplishment-report',
    role:        'attested_by',
    expiresAt:   now()->addYear(),
    maxUses:     null,      // or a cap
    signable:    null,      // or one specific record
);

A grant is scoped, expiring, revocable, and can only be created by the grantor in their own session — an administrator cannot consent on someone else's behalf (DelegationNotPermittedException).

Understand what you are enabling: a standing grant means the server can produce that user's signature for the grant's lifetime. That is a deliberate trade, not a detail.

#implicit — unsafe, off by default

Treats being tagged on a record as consent to sign it. Anyone who can edit the record can then cause that person's certificate to sign it — signature forgery with extra steps. It requires a second, separate acknowledgement:

PHP
'auto_affix' => ['mode' => 'implicit', 'allow_implicit' => true],

Without allow_implicit, the service refuses and explains why.

#Per-template override

PHP
'accomplishment-report' => [
    'auto_affix' => 'delegated',   // this template only
],

#What holds in every mode

  1. Every auto-affix is audited — who was signed for, who triggered it, which grant authorised it, IP, user agent, timestamp (SignatureAudit).
  2. Grants are created only by their grantor, in their own session.
  3. Revoking a signature or a grant takes effect immediately.
  4. Auto-affixed signatures are marked source = 'auto', distinct from draw and upload.
  5. The signatory is notified about every auto-affix, not just the grant. Silent signing is not acceptable even with consent.
  6. Machine fingerprints are never fabricated. The signer wasn't at a keyboard, so the originating signature's fingerprint is carried forward and the event is marked server-originated. Inventing one would corrupt the machine-binding check that protects ordinary signing.

#The Filament surface

#SignatoryPanel

PHP
use Kukux\DigitalSignature\Filament\Components\SignatoryPanel;

// Filament v4 / v5
public static function infolist(Schema $schema): Schema
{
    return $schema->components([
        TextEntry::make('title'),
        SignatoryPanel::make('signatories'),
    ]);
}

// Filament v3
public static function infolist(Infolist $infolist): Infolist
{
    return $infolist->schema([
        TextEntry::make('title'),
        SignatoryPanel::make('signatories'),
    ]);
}

Renders each role, who fills it, their state, and the blocker when there is one.

PHP
SignatoryPanel::make()->only(['attested_by']);   // one role in context
SignatoryPanel::make()->showPlacement();         // page + coordinates, for calibration
SignatoryPanel::make()->template('custom-key');  // override the record's template

#RequestSignaturesAction

PHP
use Kukux\DigitalSignature\Filament\Actions\RequestSignaturesAction;

RequestSignaturesAction::make()
    ->autoAffix()            // apply any standing grants immediately (default)
    ->template('custom-key');

Refuses to open a session whose required roles cannot be filled, and names the blockers instead. A session that stalls on "the Dean has no signature" is worse than no session — the document looks in-flight when nothing can happen.

On Filament v3 a table row needs RequestSignaturesTableAction instead; see Filament version compatibility.

#The inbox, and the launcher that fronts it

Two surfaces onto one queue:

  • A floating launcher — a button pinned to every panel page whose slide-over lists what is waiting, with Sign and Decline on each row. This is the default entry point, because a signatory is always in the middle of something else when a document reaches them.
  • An Awaiting my signature page, for the full view.

While the launcher is on it takes the navigation items with it: the inbox page and the Signatures resource stay routable but stop claiming sidebar slots, and the slide-over links to both. Keep both with signature.launcher.replaces_navigation = false; turn the launcher off per panel with SignaturePlugin::make()->withoutFloatingLauncher(), or the page with ->withoutInbox() / signature.inbox.enabled.

Either way each Sign click produces the signature in that user's own request, because both surfaces run the same ActsOnSignatureRequests code.

#Escape hatches

PHP
// Global signatory override — return null to defer to the slot's own binding
SignaturePlugin::make()->resolveSignatoriesUsing(
    fn ($record, $slot) => app(OrgChart::class)->holderOf($slot->role(), $record),
);

Events, for headless flows: SigningSessionOpened, SignatureRequested, SignatureDeclined, SignatureAutoAffixed, SigningSessionCompleted — alongside the existing DocumentSigned.


#Filament version compatibility

The package supports Filament 3, 4 and 5 from one codebase. Two things moved between majors and cannot be papered over:

Whatv3v4 / v5
Table row actionsFilament\Tables\Actions\ActionFilament\Actions\Action (unified)
Page::$viewstatic propertyinstance property
Forms / infolistsForms\Form, Infolists\InfolistSchemas\Schema

Declaring the wrong $view kind is a hard PHP fatal, not a graceful failure. So each affected class is split into V3\ and V4\ implementations with the behaviour in a shared trait, and the service provider aliases the canonical name at register time. Host apps import one stable name regardless of version:

PHP
use Kukux\DigitalSignature\Filament\Actions\SignDocumentAction;      // works on 3, 4, 5
use Kukux\DigitalSignature\Filament\Components\SignatoryPanel;

The one place v3 needs a different name is the table/header action split, which v3 enforces and v4 removed:

Placementv3v4 / v5
Table rowSignDocumentActionSignDocumentAction
Page headerSignDocumentHeaderActionSignDocumentAction or SignDocumentHeaderAction
Table row (request)RequestSignaturesTableActioneither
Page header (request)RequestSignaturesActioneither

Using the header-suffixed names everywhere is portable across all three.

#Version detection

PHP
FilamentVersion::major();       // 3 | 4 | 5
FilamentVersion::usesSchemas(); // bool

Detection reads Composer's installed-versions manifest, because a class probe cannot tell v4 from v5 — both have Filament\Schemas\Schema. Override with signature.filament_version when testing a branch the installed version wouldn't reach.

.github/workflows/tests.yml runs the suite across all three majors. Without that matrix, ^3.0 || ^4.0 || ^5.0 would be an aspiration rather than a guarantee.

Two things that job does beyond running Pest, both learned the hard way:

  • It requires the summary line. A fatal inside PHPUnit's output buffer — TCPDF calling die(), or a TypeError from a GD call — kills the runner, discards the buffered message, and still exits 0, silently skipping every later test file. The job greps for Tests: and compares the number of test files reported against the number on disk.
  • It runs composer audit. This is a signing package; shipping against a dependency with a live advisory is not acceptable, and the audit makes that a build failure rather than a footnote.

#Configuration reference

KeyDefaultPurpose
sessions.sequence_modesequentialsequential honours slot order; parallel doesn't
sessions.expires_after_daysnullSessions stop accepting signatures after this
multi_signature.modeprogressiveprogressive or incremental — see above
auto_affix.modeapprovalapproval, delegated, or implicit
auto_affix.allow_implicitfalseSecond acknowledgement required for implicit mode
auto_affix.notifytrueNotify the signatory on every auto-affix. Leave on.
auto_affix.default_grant_days365Default grant lifetime
inbox.enabledtrueRegister the inbox page
filament_versionautoForce a Filament branch

#Schema

Migrations run in dependency order — every foreign key's target table is created before the table that references it:

#TableHolds
1digital_user_certificatesPer-user X.509 certificate + encrypted key
2digital_signing_sessionsOne document's journey: frozen base PDF, running document, status, mode
3digital_signaturesEvery signature, including signing_session_id / slot_key / sequence / parent_signature_id for the multi-signatory chain
4signature_positionsWhere a given signature was stamped
5digital_signature_requestsOne (session, slot): assignee, frozen placement, decision
6digital_signature_delegationsStanding consent: user, signature, template, role, expiry, uses
7digital_signature_auditsAppend-only log of every consequential act
8digital_pdf_template_slotsDesigner-saved coordinates per (template, slot)

The session columns are declared in digital_signatures' own create migration rather than added by a later Schema::table() — an add_x_to_y migration is only warranted for schema that has already shipped. This is also why digital_signing_sessions is created before digital_signatures: MySQL and Postgres require a foreign key's target to exist at CREATE TABLE time, even though SQLite would accept either order.