Digital Signature for Filament GitHub

#Signatory Routing & Multi-Signatory Documents

Some documents are signed by roles: an Accomplishment Report has a Prepared by, an Attested by and a Noted by, each a person tagged on the record. This page shows how the package finds those people, sends them the document, and combines their signatures into one PDF.

New to the package? Read PDF Templates first. Routing builds 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 you already have
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 infolist entry, one action
SignatoryPanel::make('signatories');
RequestSignaturesAction::make();
  1. Open the placement designer once, at /{panel}/signature-templates/accomplishment-report/design on your panel (/admin/… on a panel with the id admin), and drag the three slots onto the signature lines. Or mark each signature space in the Blade with data-signature-slot and skip this step; see PDF Templates.

That's it. Once each signatory has registered a signature in their own panel, being tagged on a record is enough for the document to find them.


#How a slot gains a signatory

A slot can say whose rectangle it is:

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

In config, use the same keys: signatory, role, order, required.

#Four ways to bind a signatory

FormExampleUse it when
Relation name'attestedBy'The record has a belongsTo. The common case. The relation may return any model; see below.
Foreign key'attested_by_id'The record stores only an id.
Closurefn ($record) => $record->department->headThe person comes from somewhere other than a direct column.
Invokable class\App\Signatories\DepartmentHead::classYou want it reusable across templates and testable.

An invokable class can implement SignatoryResolver to get resolve($record, $slot), or just define __invoke.

A slot with no signatory is unrouted: whoever opens the signer page places their own signature. A binding that can't resolve (null foreign key, deleted parent, a closure that hits a null) doesn't throw; the slot just shows as unassigned. A closure's exception is still reported, so a genuine bug surfaces in your logs.

#Bindings that return someone other than a login

Signatures belong to logins: requests and signatures are keyed by user_id. Whatever a binding returns goes through the bound SignatoryUserMapper before it's used. The default passes logins through and refuses anything else, so a relation to a Personnel or Employee row routes as "… is named but has no login" until you bind a mapper:

PHP
// app/Providers/SignatureServiceProvider.php
$this->app->bind(SignatoryUserMapper::class, fn () => RelationUserMapper::using('user'));

Then 'attestedPersonnel' is a valid binding, and the person's login signs. In 1.x the person's own id was silently used as a user_id.


#Reading the routing

SignatoryRouter combines the slots, the saved coordinates, the tagged people and their signatures. The panel, inbox and signing pipeline all read its output.

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();

#Slot states

StateMeaningHow to fix 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_consentReady; 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 and embedded in the document—
declinedThe signatory refusedReassign the role, or cancel

blocked never hides unassigned or awaiting_registration; those always show.

#Shortcuts on HasSignatories

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());
$report->currentSigningSession();
$report->latestSigningSession();

#Signing sessions

A SigningSession owns a multi-signatory document from start to finish.

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

Opening a session:

  • Freezes the PDF. It's rendered once into base_document_path and never re-rendered, so later signers stamp on top of earlier ones.
  • Creates one SignatureRequest per slot, with the placement copied in. Moving a slot in the designer afterwards doesn't affect open requests.

Calling it again just returns the open session, with assignments refreshed.

#Sequential vs parallel

PHP
'sessions' => ['sequence_mode' => 'sequential'],   // default
  • sequential follows the slots' order. Signing out of turn throws OutOfSequenceException.
  • parallel lets any assigned signatory sign at any time.

Override per template with 'sequence_mode' => 'parallel'.

#Late assignment

A session can open before every role is filled. After tagging someone later, run:

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

Requests that are already signed or declined are left alone.

#The signature chain

Each signature links 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 N-1's signed_document_hash, and parent_signature_id stores the link, so the chain is verifiable from the database alone.


#Signing modes — read this before choosing

PHP
'multi_signature' => ['mode' => 'progressive'],   // default
progressive (default)incremental
How it worksEach signature is stamped onto the previous output and re-signed with that signer's certificateEach signature is appended as an ISO 32000 incremental update (true PAdES)
Visible signatures in the PDFAll of themAll of them
Cryptographic signatures in the PDFOnly the last oneOne per signer, all valid
Per-signer DB row, certificate fingerprint, timestamp, hash chainYesYes
Works with bundled driversYesNo

incremental needs a driver implementing SupportsIncrementalSigning. The bundled FPDI and TCPDF drivers rewrite the whole file, so they can't; you'd need something like SetaPDF-Signer. Without one, signing throws IncrementalSigningUnsupportedException rather than silently falling back.

Use progressive if a visible signature block, tamper-evidence and an audit trail are enough. Use incremental if each signer's certificate must verify inside the PDF itself. If that's a legal requirement, confirm it before building on progressive.


Automation can save signers effort, but it must never sign without their consent. Pick a mode:

PHP
'auto_affix' => ['mode' => 'approval'],   // default
ModeWhat happens
approval (default)Never auto-signs. The document lands in the signer's inbox with the placement filled in, and they sign it with one click in their own request, with their own certificate.
delegatedThe signer opts in once with a standing grant. Matching documents are then signed for them automatically.
implicitBeing tagged on a record counts as consent. Unsafe: anyone who can edit the record can make that person's certificate sign it. Off unless you also set allow_implicit.

#delegated grants

The signer creates the grant from their own account:

PHP
app(AutoAffixService::class)->grant(
    grantorId:   auth()->id(),
    signature:   $mySignature,
    templateKey: 'accomplishment-report',
    role:        'attester',         // optional; must match the slot's role() (its `role`, or its key)
    expiresAt:   now()->addYear(),   // defaults to auto_affix.default_grant_days
    maxUses:     null,               // or a cap
    signable:    null,               // or one specific record
);

Revoke it with app(AutoAffixService::class)->revokeGrant($delegation).

Only the grantor can create or revoke a grant, in their own session. Anyone else (an admin included) gets DelegationNotPermittedException, as does a grant for a signature that isn't the grantor's or is revoked.

Keep in mind: while a grant is live, the server can produce that user's signature.

#implicit needs a second switch

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

  • Every auto-affix is audited in SignatureAudit: who was signed for, who triggered it, which grant allowed it, IP, user agent and time.
  • Revoking a signature or a grant takes effect immediately.
  • Auto-affixed signatures get source = 'auto', separate from draw and upload.
  • The signer is notified about every auto-affix, not just the grant (auto_affix.notify).
  • Machine fingerprints are never faked. The original signature's fingerprint is carried forward and the event is marked as server-originated.

The reasoning behind these rules is in Concept: Signatory Routing.


#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'),
    ]);
}

It shows each role, who fills it, its state, and the blocker if there is one.

PHP
SignatoryPanel::make()->only(['attested_by']);   // show just these roles
SignatoryPanel::make()->showPlacement();         // page + coordinates, for calibration
SignatoryPanel::make()->showAvatars();           // signer avatars (bool or closure)
SignatoryPanel::make()->template('custom-key');  // override the record's template

#RequestSignaturesAction

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

RequestSignaturesAction::make()
    ->autoAffix()            // apply standing grants right after opening (default)
    ->template('custom-key');

It refuses to open a session while required roles can't be filled, and lists the blockers. autoAffix() only does something if the template's consent mode allows it. On Filament v3, table rows need RequestSignaturesTableAction (see Filament version compatibility).

#The inbox and the floating launcher

  • Floating launcher (default): a button on every panel page that opens a slide-over of what's waiting, with Sign and Decline on each row.
  • Awaiting my signature page: the full-page view.

While the launcher is on, the inbox page and Signatures resource leave the sidebar but stay routable; the slide-over links to both.

You want toDo this
Keep the sidebar items toosignature.launcher.replaces_navigation = false
Turn the launcher off for a panelSignaturePlugin::make()->withoutFloatingLauncher()
Turn the launcher off everywheresignature.launcher.enabled = false
Turn the inbox page off->withoutInbox() or signature.inbox.enabled = false

Both run the same ActsOnSignatureRequests code, so every Sign click signs in that user's own request.

#Escape hatches

PHP
// Global signatory override — return null to fall back 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, SignatoryTurnReached, SignatureDeclined, SignatureAutoAffixed, SigningSessionCompleted, plus the existing DocumentSigned.

Who gets notified, and when. Each signatory gets SignatureRequestedNotification once, when they can actually sign. In a sequential session that's the first signatory when the session opens, then each next one as the person before them signs. In a parallel session it's everyone at once. A signatory tagged after the session opened is notified when their turn comes. The package sends it on SignatoryTurnReached. SignatureRequested still fires for every slot the moment it's routed, if you need that instead. Channels come from sessions.notification_channels; set it to [] to send nothing.


#Filament version compatibility

One codebase supports Filament 3, 4 and 5. These changed between majors:

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

You don't need to care: import the same class name on every version and the service provider aliases the right implementation.

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

The one exception is v3's split between table and header actions:

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

The header-suffixed names work as header actions on all three versions.

#Version detection

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

Detection reads Composer's installed-versions data. To force a branch (for example in tests), set signature.filament_version / SIGNATURE_FILAMENT_VERSION.

CI (.github/workflows/tests.yml) runs the suite on all three majors, fails if Pest's Tests: summary is missing or incomplete, and runs composer audit.


#Configuration reference

KeyEnv varDefaultPurpose
sessions.sequence_modeSIGNATURE_SEQUENCE_MODEsequentialsequential follows slot order; parallel doesn't
sessions.expires_after_daysSIGNATURE_SESSION_EXPIRY_DAYSnullSessions stop accepting signatures after this many days
multi_signature.modeSIGNATURE_MULTI_MODEprogressiveprogressive or incremental
auto_affix.modeSIGNATURE_AUTO_AFFIX_MODEapprovalapproval, delegated or implicit
auto_affix.allow_implicitSIGNATURE_ALLOW_IMPLICIT_AFFIXfalseSecond switch required for implicit
auto_affix.notifySIGNATURE_AUTO_AFFIX_NOTIFYtrueNotify the signer on every auto-affix. Leave it on.
auto_affix.default_grant_daysSIGNATURE_GRANT_DAYS365Default grant lifetime
inbox.enabledSIGNATURE_INBOX_ENABLEDtrueRegister the inbox page
inbox.navigationSIGNATURE_INBOX_NAVtrueShow the inbox in the sidebar (page stays routable either way)
launcher.enabledSIGNATURE_LAUNCHER_ENABLEDtrueShow the floating launcher
launcher.replaces_navigationSIGNATURE_LAUNCHER_REPLACES_NAVtrueHide inbox/resource sidebar items while the launcher is on
filament_versionSIGNATURE_FILAMENT_VERSIONautoForce a Filament branch (3, 4 or 5)

Per-template overrides: sequence_mode and auto_affix inside a templates.<key> entry. The launcher's look and placement options are documented in config/signature.php.


#Schema

All tables are created by a single migration, 9999_12_31_000000_create_digital_signature_tables.php, in dependency order: every foreign key's target table is created before the table that references it. These are the tables this page relies on:

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

The order matters because MySQL and Postgres require a foreign key's target to exist at CREATE TABLE time, even though SQLite would accept either order. Every create is guarded by Schema::hasTable and every column added in a later release by Schema::hasColumn. That makes the migration safe to run on databases created by earlier releases.