#Recipe: a document that already is a record (Travel Request)
For a document that is already a row before anyone routes it, like a Travel Request someone filed. The record is the subject: there's nothing to find or create, so the definition is shorter than the period recipe.
Placeholders.
TravelRequest,OfficeChainSignatoryDefaultsand the column names stand in for your app's.
The document: a travel request, recommended for approval by the requester's unit head, then approved by the director. It can't be routed until its itinerary is approved.
#1. Composition root
// app/Providers/SignatureServiceProvider.php
class SignatureServiceProvider extends ServiceProvider
{
public function register(): void
{
// Whoever your approvers are; see "external signatories" if they come from another system.
$this->app->bind(SignatoryUserMapper::class, fn () => RelationUserMapper::using('user'));
}
}// config/signature.php
'templates' => [\App\Pdf\TravelRequestTemplate::class],
'documents' => ['travel-request' => \App\Signatures\TravelRequestDocument::class],#2. Migration and model
Schema::table('travel_requests', function (Blueprint $table) {
SignatoryColumns::add($table, ['recommending', 'approving'], references: 'employees');
});class TravelRequest extends Model implements Signable
{
use HasPdfTemplate, HasSignatories, SnapshotsSignatories;
protected string $signaturePdfTemplate = 'travel-request';
public function signatoryRoles(): array
{
return ['recommending', 'approving'];
}
public function signatoryModel(): string
{
return Employee::class;
}
public function requester(): BelongsTo
{
return $this->belongsTo(Employee::class, 'requester_id');
}
public function recommending(): BelongsTo
{
return $this->signatoryRelation('recommending');
}
public function approving(): BelongsTo
{
return $this->signatoryRelation('approving');
}
}#3. Template and view
As in the period recipe. Three slots bound to requester, recommending and approving, each marked data-signature-slot in the view.
#4. Definition
The inherited locate() / open() return the record. Only the guard and the signatory snapshot are added:
use Kukux\DigitalSignature\Documents\AbstractSignableDocument;
class TravelRequestDocument extends AbstractSignableDocument
{
public function __construct(private OfficeChainSignatoryDefaults $defaults) {}
public function template(): string
{
return 'travel-request';
}
public function guards(): array
{
return [ItineraryIsApproved::class];
}
public function open(mixed $request, array $context = []): Model
{
$request->snapshotSignatories($this->defaults);
$request->save();
return $request;
}
}The guard checks the opened record, inside the routing transaction:
use Kukux\DigitalSignature\Contracts\PdfTemplate;
use Kukux\DigitalSignature\Contracts\RoutingGuard;
use Kukux\DigitalSignature\Routing\RoutingResult;
final class ItineraryIsApproved implements RoutingGuard
{
public function check(Model $record, PdfTemplate $template, array $context): ?RoutingResult
{
return $record->itinerary_approved_at === null
? RoutingResult::refused('itinerary_not_approved', title: 'Itinerary not approved', body: 'Approve the itinerary before routing this request.')
: null;
}
}Who recommends and approves comes from the org chart, injected:
final class OfficeChainSignatoryDefaults implements SignatoryDefaults
{
public function __construct(private OrgChart $chart) {}
public function for(Model $record, string $role): ?SignatoryAssignment
{
$person = match ($role) {
'recommending' => $this->chart->unitHeadOf($record->requester),
'approving' => $this->chart->directorOf($record->requester),
};
return $person ? new SignatoryAssignment($person->id, $person->position_title) : null;
}
}#5. UI
A record page, so no subject or context:
protected function getHeaderActions(): array
{
return [
RouteForSignaturesAction::make()->document('travel-request'),
ViewDocumentOfRecordAction::make()->document('travel-request'),
ViewDocumentHistoryAction::make()->document('travel-request'),
];
}Or show the history inline: DocumentHistoryEntry::make() in the infolist.
#6. Test
class TravelRequestDocumentContractTest extends TestCase
{
use RefreshDatabase, SignableDocumentContract;
protected function documentKey(): string
{
return 'travel-request';
}
protected function routableSubject(): array
{
return [TravelRequest::factory()->itineraryApproved()->withSignatories()->create()];
}
protected function unroutableSubject(): ?array
{
return [TravelRequest::factory()->withSignatories()->create()]; // itinerary not approved
}
}test_a_refusal_leaves_no_record_behind only checks for a leftover record when routing created one. Here the record existed beforehand, so it checks the refusal alone.
#Checklist
composer require kukux/digital-signature:^2.0, thenphp artisan signature:install.- Composition root: create
App\Providers\SignatureServiceProvider, add it tobootstrap/providers.php, and bindSignatoryUserMapperunless your signatories areUserrows. - Migration:
SignatoryColumns::add(...)for each role you snapshot. - Model:
implements Signable;use HasPdfTemplate, HasSignatories, SnapshotsSignatories;signatoryRoles(),signatoryModel(), and asignatoryRelation()per role. - Template: a class in
config('signature.templates'), with slots bound to relation names andrenderer: new DomPdfRenderer(paper: …). - View:
data-signature-slot="<key>"on every signature space. - Definition: a class in
config('signature.documents'), with its guards and aSignatoryDefaultstaken in its constructor. - UI:
RouteForSignaturesAction, plusViewDocumentOfRecordAction/DownloadDocumentOfRecordAction/ViewDocumentHistoryAction. - Test:
use SignableDocumentContract. When it passes, the integration is done. - Designer: open the template once and check the sample placements.