In modern web applications built on Laravel, contract generation frequently represents the culmination of an internal business approval process. An estimate is prepared, a sales manager approves a customized discount, finance reviews payment terms, and legal validates compliance. Once all internal stakeholders grant approval, the contract must transition seamlessly into a formal, legally binding electronic signature request.
Too often, developers implement this transition using fragile synchronous HTTP calls in web controllers, leaving database states vulnerable to timeouts, race conditions, and unhandled webhook dropouts. When a network hiccup occurs between your application and the signing API, agreements get sent but database state remains stuck, or internal approvals pass without agreements ever reaching customers.
Here is an architectural guide to building an enterprise-grade contract approval-to-signature workflow in Laravel using Eloquent state machines, queued background jobs, resilient webhook listeners, and cryptographic validation.
The Architecture: State Machine & Queued Jobs
A reliable contract pipeline decouples internal business logic from external API communication across four structured stages:
| Workflow Stage | Laravel Component | System Responsibility |
|---|---|---|
| 1. Internal Approval | Eloquent Model & Policies | Validating user authorization, transitioning contract state from draft to approved. |
| 2. Background Dispatch | Queued Job (DispatchSignatureRequest) |
Serializing contract data, executing authenticated API call with exponential backoff. |
| 3. Webhook Receiver | Controller & Middleware | Validating HMAC signatures, verifying payload integrity, returning immediate HTTP 200. |
| 4. Asynchronous Filing | Queued Event (ProcessSignedContract) |
Downloading executed PDF, storing in Storage disk, updating database state to completed. |
Step 1: Managing Contract States with Eloquent
Model your contract lifecycle using explicit states rather than ambiguous boolean flags. Define an enum or state transitions on your Contract model:
enum ContractStatus: string {
case Draft = 'draft';
case Approved = 'approved';
case Sending = 'sending';
case OutForSignature = 'out_for_signature';
case Completed = 'completed';
case Declined = 'declined';
}
When an authorized manager approves the agreement, update the status inside a database transaction and dispatch a queued job:
public function approve(Contract $contract, User $approver): void
{
DB::transaction(function () use ($contract, $approver) {
$contract->update([
'status' => ContractStatus::Approved,
'approved_at' => now(),
'approved_by' => $approver->id,
]);
DispatchSignatureRequest::dispatch($contract);
});
}
Step 2: Resilient Queued Dispatch Job
External network calls should never execute within web requests. Encapsulate the API integration inside a queued job configured with automatic retries and exponential backoff:
namespace App\Jobs;
use App\Models\Contract;
use App\Models\ContractStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class DispatchSignatureRequest implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $backoff = 30;
public function __construct(public Contract $contract) {}
public function handle(): void
{
$response = Http::withToken(config('services.undersign.api_key'))
->timeout(15)
->post(config('services.undersign.endpoint') . '/v1/documents', [
'template_id' => $this->contract->template_id,
'recipients' => [
[
'name' => $this->contract->client_name,
'email' => $this->contract->client_email,
'role' => 'client',
],
],
'fields' => [
'contract_value' => number_format($this->contract->total_amount, 2),
'effective_date' => $this->contract->start_date->toDateString(),
],
'metadata' => [
'internal_contract_id' => $this->contract->id,
],
]);
if ($response->successful()) {
$this->contract->update([
'status' => ContractStatus::OutForSignature,
'external_document_id' => $response->json('document_id'),
'sent_at' => now(),
]);
} else {
$this->release(30);
}
}
}
Step 3: Webhook Verification and Storage Handling
When the client executes the contract, the signing engine posts an asynchronous webhook to your application. Secure the webhook endpoint using dedicated middleware to verify the HMAC-SHA256 signature before touching application logic:
public function handleWebhook(Request $request): JsonResponse
{
$signature = $request->header('X-Signature-SHA256');
$payload = $request->getContent();
$expected = hash_hmac('sha256', $payload, config('services.undersign.webhook_secret'));
if (!hash_equals($expected, $signature)) {
abort(401, 'Invalid signature header.');
}
$data = $request->json()->all();
if ($data['event'] === 'document.completed') {
ProcessSignedContract::dispatch($data['metadata']['internal_contract_id'], $data['document_id']);
}
return response()->json(['status' => 'acknowledged']);
}
Inside the background ProcessSignedContract worker, download the final tamper-evident PDF and store it securely using Laravel's Storage facade, updating the contract state to completed:
$fileContents = Http::withToken(config('services.undersign.api_key'))
->get(config('services.undersign.endpoint') . "/v1/documents/{$documentId}/download")
->body();
$filePath = "contracts/{$contract->id}/executed_contract.pdf";
Storage::disk('s3')->put($filePath, $fileContents);
$contract->update([
'status' => ContractStatus::Completed,
'file_path' => $filePath,
'completed_at' => now(),
]);
Engineering Summary
By leveraging Laravel's native queue architecture, database transactions, and cryptographic verification, development teams construct an approval-to-signature pipeline that eliminates race conditions, handles third-party network interruptions gracefully, and provides a completely automated contract lifecycle for modern business applications.