PDF Tamper Detection in Laravel: Http Client, Queued Job, Batch Backfill and Pest Tests

This article is a snapshot – content was accurate as of June 2026 (code examples tested against the API as of October 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication – see the changelog for the current state.
A user uploads a PDF to your Laravel app. A queue worker picks it up a moment later. Somewhere between those two events you have to decide whether anything downstream should trust the file: the payslip behind a loan application, the invoice behind a payout, the statement behind an onboarding step. This guide builds that decision into Laravel the way a Laravel developer would expect it: a service class bound in the container, the Http facade with a timeout and retries, a queued job that survives restarts and capacity limits without paying for the same document twice, an Eloquent model that records what happened, Pest tests that never touch the network, and an Artisan backfill built on Bus::batch() for the documents that were already in your database before the integration existed.
The PDF tamper detection API is two calls — submit a URL, read the result — and that part is the same in every language. What differs is how it fits the framework. For the language-neutral overview see How to Detect PDF Tampering Programmatically; for the same API from other stacks see the Go, Node.js and Python guides, and for running thousands of documents through a queue see batch PDF verification at scale.
TL;DR
POST /analyzetakes a public PDF URL and returns{"id": "…"}with201;GET /result/{id}returns the verdict:statusisintact,modifiedorinconclusive.- Give the HTTP client a 60-second timeout. The API allows up to 30 seconds to download the file and 20 more to analyse it, so the 30 seconds that suits most APIs is too short here.
- Send an
Idempotency-KeyonPOST /analyzeand persist it before the request goes out. A retry after a dropped connection then returns the same check id instead of spending a second credit. - In a queued job,
release()on429(usingRetry-After) and409,fail()on permanent4xx, and rethrow on5xx. UseretryUntil()andmaxExceptionsrather than$tries, because released attempts count against$tries. - Map the three verdicts to model states. A
modifieddocument goes to a human reviewer; the API says outright that a verdict must never be the sole basis for an automatic adverse decision.
Prerequisites
- PHP 8.2+ (the code uses
readonlyclasses and backed enums) - Laravel 11 or later, with a queue connection and an S3-compatible disk (the patterns apply to Lumen and plain PHP with Guzzle too)
- An HTPBE? API key: live keys start with
htpbe_live_, test keys withhtpbe_test_. Every account has one test key, shown on the API keys page of the dashboard
How a check flows through Laravel
upload ─▶ store on private disk ─▶ Document(pending) ─▶ VerifyDocumentJob
│
POST /analyze {url} (Idempotency-Key) ◀──────┤ persist key + check id
GET /result/{id} ◀──────┘
▼
Document: cleared | human_review | issuer_check | failedThe API needs a URL it can fetch without authentication, so the pattern throughout is a short-lived signed URL (Storage::temporaryUrl()) minted by the job, not a public bucket and not a file upload.
Step 1: Check the key with curl
Before writing PHP, confirm the key and the network path. The test URLs return fixed, synthetic results and consume no credits; only test keys can use them.
curl -X POST https://api.htpbe.tech/v1/analyze \
-H "Authorization: Bearer $HTPBE_TEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.htpbe.tech/v1/test/modified-high.pdf"}'
# {"id":"00000000-0000-4000-8000-000000000005"}
curl https://api.htpbe.tech/v1/result/00000000-0000-4000-8000-000000000005 \
-H "Authorization: Bearer $HTPBE_TEST_API_KEY"The second call returns a flat JSON object. The fields this guide uses:
{
"id": "00000000-0000-4000-8000-000000000005",
"filename": "modified-high.pdf",
"algorithm_version": "2.44.5",
"status": "modified",
"creator": "Adobe Acrobat Pro DC",
"producer": "PDFtk Server 2.02",
"modification_markers": ["HTPBE_EDITING_TOOL_FINGERPRINT"],
"usage_caution": {
"safe_for_automated_adverse_decision": false,
"recommended_action": "route_to_human_review",
"message": "Structural evidence of post-creation modification was found. …"
}
}status_reason appears only when status is inconclusive. modification_markers is a list of stable HTPBE_* ids, empty unless the file is modified; branch on the id and render labels from the dictionary on how it works. usage_caution.recommended_action is route_to_human_review for modified, request_from_issuer for inconclusive and no_action for intact.
Step 2: Config and environment
Only the key and the timeout are environment-specific. The base URL is a constant.
config/services.php
'htpbe' => [
'api_key' => env('HTPBE_API_KEY'),
'base_url' => 'https://api.htpbe.tech/v1',
'timeout' => 60,
],.env and .env.example
HTPBE_API_KEY=Production gets a htpbe_live_ key. The test key goes in .env.testing and CI secrets as HTPBE_TEST_API_KEY, used only by the smoke test in Step 9. Because a test key rejects every URL that is not one of the documented mock URLs (403 test_url_required), a staging environment on a test key exercises the full flow but cannot analyse your real uploads. Staging that needs real files needs a live key.
Step 3: The result type
A readonly DTO plus a backed enum keeps the rest of the app away from raw arrays. The enum is deliberately strict: if the API ever returned a status your code does not know, HTPBEStatus::from() throws and the job fails instead of treating an unknown value as safe.
app/Services/HTPBE/HTPBEStatus.php
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
enum HTPBEStatus: string
{
case Intact = 'intact';
case Modified = 'modified';
case Inconclusive = 'inconclusive';
}app/Services/HTPBE/HTPBEResult.php
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
final readonly class HTPBEResult
{
/** @param list<string> $modificationMarkers stable HTPBE_* ids */
public function __construct(
public string $id,
public HTPBEStatus $status,
public ?string $statusReason,
public array $modificationMarkers,
public string $recommendedAction,
public string $algorithmVersion,
public ?string $creator,
public ?string $producer,
) {}
/** @param array<string, mixed> $payload */
public static function fromArray(array $payload): self
{
foreach (['id', 'status', 'modification_markers', 'usage_caution', 'algorithm_version'] as $key) {
if (! array_key_exists($key, $payload)) {
throw new \DomainException("HTPBE result is missing field: {$key}");
}
}
return new self(
id: (string) $payload['id'],
status: HTPBEStatus::from($payload['status']),
statusReason: $payload['status_reason'] ?? null,
modificationMarkers: array_values($payload['modification_markers']),
recommendedAction: (string) ($payload['usage_caution']['recommended_action'] ?? ''),
algorithmVersion: (string) $payload['algorithm_version'],
creator: $payload['creator'] ?? null,
producer: $payload['producer'] ?? null,
);
}
}Step 4: The exception
Every API error body has the same shape: error, a machine-readable code, and sometimes details. The codes are lowercase snake_case, and they are what your code should branch on.
app/Services/HTPBE/HTPBEException.php
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
use RuntimeException;
final class HTPBEException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $statusCode,
public readonly string $errorCode,
public readonly ?int $retryAfterSeconds = null,
) {
parent::__construct($message, $statusCode);
}
/** A 4xx that will fail identically on every retry. */
public function isPermanent(): bool
{
return $this->statusCode >= 400
&& $this->statusCode < 500
&& ! in_array($this->statusCode, [409, 429], true);
}
}409 (idempotency_key_in_progress) and 429 (server_at_capacity) are the two 4xx responses that succeed later with the same request, so they are excluded from isPermanent().
Step 5: The client
The client wraps the Http facade’s factory. Three decisions are worth explaining.
Timeout. The API downloads your file (30-second limit) and then analyses it (20-second limit), and the response only comes back when both are done. Typical documents take 2–5 seconds, but a client timeout below the combined worst case turns slow-but-valid files into spurious failures.
Retries. retry() re-sends after connection errors, 5xx, and 409. It does not retry other 4xx. It also does not retry 429: that response carries a Retry-After, and a queue job can wait out a minute of server load without holding a worker, which a blocking sleep inside retry() cannot.
Idempotency. If a connection drops after the request left your server, you cannot know whether the document was charged. The caller supplies an Idempotency-Key and reuses the same value for the same document. The retry then returns 201 with the original check id and no second charge. Reusing the key with a different URL is rejected (422 idempotency_key_fingerprint_mismatch), which is why the job in Step 7 stores the URL together with the key.
app/Services/HTPBE/HTPBEClient.php
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
final class HTPBEClient
{
public function __construct(
private readonly HttpFactory $http,
private readonly string $apiKey,
private readonly string $baseUrl,
private readonly int $timeoutSeconds,
) {
if ($apiKey === '') {
throw new \InvalidArgumentException('HTPBE_API_KEY is not configured.');
}
}
/** Submit a PDF URL. Returns the check id. */
public function submit(string $pdfUrl, string $idempotencyKey, ?string $originalFilename = null): string
{
$body = ['url' => $pdfUrl];
if ($originalFilename !== null) {
$body['original_filename'] = $originalFilename;
}
$response = $this->request()
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->post('/analyze', $body);
$this->ensureSuccess($response);
return (string) $response->json('id');
}
public function getResult(string $checkId): HTPBEResult
{
$response = $this->request()->get('/result/' . $checkId);
$this->ensureSuccess($response);
return HTPBEResult::fromArray($response->json());
}
/**
* @return array{data: list<array<string, mixed>>, total: int, has_more: bool}
*/
public function listChecks(?HTPBEStatus $status = null, int $limit = 100, int $offset = 0): array
{
$response = $this->request()->get('/checks', array_filter([
'status' => $status?->value,
'limit' => $limit,
'offset' => $offset,
], fn ($value) => $value !== null));
$this->ensureSuccess($response);
return $response->json();
}
private function request(): PendingRequest
{
return $this->http
->baseUrl($this->baseUrl)
->withToken($this->apiKey)
->acceptJson()
->timeout($this->timeoutSeconds)
->retry(3, 1000, function (\Throwable $e): bool {
if ($e instanceof ConnectionException) {
return true;
}
if (! $e instanceof RequestException) {
return false;
}
$status = $e->response->status();
return $status >= 500 || $status === 409;
}, throw: false);
}
private function ensureSuccess(Response $response): void
{
if ($response->successful()) {
return;
}
$message = (string) ($response->json('error') ?? $response->reason());
$details = $response->json('details');
throw new HTPBEException(
message: $details ? "{$message} ({$details})" : $message,
statusCode: $response->status(),
errorCode: (string) ($response->json('code') ?? 'unknown'),
retryAfterSeconds: $this->retryAfter($response),
);
}
private function retryAfter(Response $response): ?int
{
$header = trim($response->header('Retry-After'));
// Seconds, clamped so a bad value cannot park a job for hours.
return ctype_digit($header) ? max(1, min(600, (int) $header)) : null;
}
}ensureSuccess() does not translate codes into messages of its own. The API’s error and details strings are already human-readable, and the stable part — errorCode — is what callers match on.
Step 6: Bind it in the container
app/Providers/HTPBEServiceProvider.php
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\HTPBE\HTPBEClient;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
final class HTPBEServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(HTPBEClient::class, fn ($app) => new HTPBEClient(
http: $app->make(HttpFactory::class),
apiKey: (string) config('services.htpbe.api_key'),
baseUrl: (string) config('services.htpbe.base_url'),
timeoutSeconds: (int) config('services.htpbe.timeout'),
));
}
}Add App\Providers\HTPBEServiceProvider::class to bootstrap/providers.php. Because the closure runs on first resolution, a missing key fails the first job that needs it, not every artisan command.
Step 7: Model state and the queued job
What gets stored
The model needs to answer three questions later: what did we decide, why, and which check backs it. It also carries the two columns that make retries safe.
Schema::table('documents', function (Blueprint $table) {
$table->string('verification_state')->default('pending')->index();
$table->string('htpbe_check_id')->nullable()->unique();
$table->string('htpbe_status')->nullable();
$table->string('htpbe_status_reason')->nullable();
$table->json('htpbe_markers')->nullable();
$table->string('htpbe_algorithm_version')->nullable();
$table->string('htpbe_error_code')->nullable();
$table->string('htpbe_submission_key')->nullable();
$table->text('htpbe_submission_url')->nullable(); // encrypted cast
$table->timestamp('verified_at')->nullable();
});app/Enums/VerificationState.php — the mapping from API verdict to your business state lives in one place:
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Services\HTPBE\HTPBEStatus;
enum VerificationState: string
{
case Pending = 'pending';
case Cleared = 'cleared';
case HumanReview = 'human_review';
case IssuerCheck = 'issuer_check';
case Failed = 'failed';
public static function fromStatus(HTPBEStatus $status): self
{
return match ($status) {
HTPBEStatus::Intact => self::Cleared,
HTPBEStatus::Modified => self::HumanReview,
HTPBEStatus::Inconclusive => self::IssuerCheck,
};
}
}On Document, add the new columns to $fillable (the job writes the submission key and URL with forceFill, everything else with update) and cast the state and the secret:
protected function casts(): array
{
return [
'verification_state' => VerificationState::class,
'htpbe_markers' => 'array',
'htpbe_submission_url' => 'encrypted',
'verified_at' => 'datetime',
];
}Read the three non-failure states as follows:
Cleared(intact): no structural evidence of modification. This is not a certificate of authenticity — it says nothing about whether the content was truthful when the file was first created.HumanReview(modified): put the file in front of a person, withhtpbe_markersas context. The response carriessafe_for_automated_adverse_decision: falseon every verdict, so do not auto-reject an applicant or claimant on this field alone.IssuerCheck(inconclusive): the structural check does not apply — the file came from consumer software, an online editor, a scan, a filled form or a Fill & Sign overlay, andstatus_reasonsays which. The API treats this as neither a pass nor a failure. Handle every reason with the same fallback, ask the issuer for the document, and keephtpbe_status_reasonfor the reviewer. A re-rendered file (unverifiable_metadata) belongs on the same list. Forconsumer_software_originon a document that claims institutional origin, such as a payslip or bank statement, the result itself is a reason to verify with the issuer before relying on it.
The job
Laravel gives you three ways to leave a job, and each maps to a different class of API error:
| API response | Job action |
|---|---|
429 server_at_capacity, 409 idempotency_key_in_progress | release($retryAfter) |
permanent 4xx (payment_required, download_failed, invalid_pdf, file_too_large, invalid_api_key, and so on) | record the code, then fail() |
5xx, timeouts, anything unexpected | rethrow; backoff() spaces retries |
A released job still consumes an attempt, so a few capacity releases in a row would exhaust $tries and fail a perfectly healthy job. The job below leaves $tries unset, bounds the whole effort with retryUntil(), and counts only real exceptions with maxExceptions.
app/Jobs/VerifyDocumentJob.php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Enums\VerificationState;
use App\Models\Document;
use App\Services\HTPBE\HTPBEClient;
use App\Services\HTPBE\HTPBEException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class VerifyDocumentJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $maxExceptions = 3;
// submit and getResult can each spend 3 x 60 s on HTTP retries; the worker's
// --timeout and the connection's retry_after in config/queue.php must exceed this.
public int $timeout = 400;
public bool $deleteWhenMissingModels = true;
public function __construct(public Document $document) {}
public function retryUntil(): \DateTimeInterface
{
// Fixed deadline, shorter than the signed URL's lifetime in submit().
return $this->document->created_at->addMinutes(25);
}
/** @return list<int> */
public function backoff(): array
{
return [10, 60, 300];
}
public function handle(HTPBEClient $client): void
{
$document = $this->document;
if ($document->verification_state !== VerificationState::Pending) {
return;
}
try {
$checkId = $document->htpbe_check_id ?? $this->submit($client, $document);
$result = $client->getResult($checkId);
} catch (HTPBEException $e) {
$this->handleApiError($document, $e);
return;
}
$document->update([
'verification_state' => VerificationState::fromStatus($result->status),
'htpbe_status' => $result->status->value,
'htpbe_status_reason' => $result->statusReason,
'htpbe_markers' => $result->modificationMarkers,
'htpbe_algorithm_version' => $result->algorithmVersion,
'htpbe_submission_key' => null,
'htpbe_submission_url' => null,
'verified_at' => now(),
]);
}
private function submit(HTPBEClient $client, Document $document): string
{
// Key and URL are stored together before the request goes out: a retry
// with the same key but a freshly signed URL would be rejected as a
// different document, and a retry with a new key could be charged twice.
if ($document->htpbe_submission_key === null) {
$document->forceFill([
'htpbe_submission_key' => (string) Str::uuid(),
'htpbe_submission_url' => Storage::disk($document->disk)
->temporaryUrl($document->path, now()->addMinutes(30)),
])->save();
}
$checkId = $client->submit(
$document->htpbe_submission_url,
$document->htpbe_submission_key,
$document->original_filename,
);
// From here a retry skips POST /analyze entirely and only re-reads the result.
$document->update(['htpbe_check_id' => $checkId]);
return $checkId;
}
private function handleApiError(Document $document, HTPBEException $e): void
{
if (in_array($e->errorCode, ['server_at_capacity', 'idempotency_key_in_progress'], true)) {
$this->release($e->retryAfterSeconds ?? 30);
return;
}
if ($e->isPermanent()) {
// payment_required means the credit pool is empty: every queued job
// will fail the same way until it is topped up, so alert on it.
$document->update([
'verification_state' => VerificationState::Failed,
'htpbe_error_code' => $e->errorCode,
]);
$this->fail($e);
return;
}
throw $e;
}
}The two-phase shape is the point of the job. Phase one (submit) is the only step that spends a credit, and it stores the check id the moment the API returns it. Phase two (getResult) is a read: it can be repeated freely, so a worker crash between the two never costs a second check.
A failed document stays failed until you reset it. Put the reset on the model, so recovery after a top-up or a re-upload is one call, and the next attempt mints a fresh key and a fresh signed URL (a failed request has released its credit hold, so a new key is safe):
// App\Models\Document
public function resetForRetry(): void
{
$this->forceFill([
'verification_state' => VerificationState::Pending,
'htpbe_error_code' => null,
'htpbe_submission_key' => null,
'htpbe_submission_url' => null,
])->save();
VerifyDocumentJob::dispatch($this)->afterCommit()->onQueue('verification');
}The job’s 25-minute deadline counts from created_at, so for a document older than that, extend the deadline (or key it off an updated_at you touch in resetForRetry()) before relying on this path.
Dispatch it from the upload endpoint after storing the file:
public function store(Request $request): JsonResponse
{
$request->validate([
'document' => ['required', 'file', 'mimes:pdf', 'max:10240'], // 10 MB
]);
$file = $request->file('document');
$path = Storage::disk('s3')->putFileAs('incoming', $file, Str::uuid() . '.pdf');
$document = Document::create([
'user_id' => $request->user()->id,
'disk' => 's3',
'path' => $path,
'original_filename' => $file->getClientOriginalName(),
]);
VerifyDocumentJob::dispatch($document)->afterCommit()->onQueue('verification');
return response()->json(['document_id' => $document->id], 202);
}max:10240 mirrors the API’s 10 MB limit so oversized files never reach your bucket (the API answers those with 413 file_too_large). A dedicated verification queue and its own worker keep a backlog of PDFs from starving everything else; php artisan queue:work --queue=verification --timeout=400.
Step 8: Error code reference
All of these arrive as HTPBEException with errorCode set to the code from the response body.
| Status | errorCode | Meaning | In the job |
|---|---|---|---|
| 400 | invalid_request | Malformed JSON body, missing url, or a bad check id | Permanent: fix the code |
| 400 | invalid_url_format | url is not a valid HTTP or HTTPS URL | Permanent |
| 400 | download_failed | The API could not fetch the URL (404, 403, timeout, expired link) | Permanent: resetForRetry() |
| 401 | missing_api_key / invalid_api_key | No key sent, or the key is not recognised | Permanent: fix config |
| 402 | payment_required | No credit source left for this request | Permanent: alert, top up, resetForRetry() |
| 403 | inactive_client | The key has been deactivated | Permanent |
| 403 | test_url_required | A test key was used with a real URL | Permanent: use a live key |
| 403 | account_restricted | Account-level restriction | Permanent: contact support |
| 404 | not_found | Unknown check id, or it belongs to another key | Permanent |
| 409 | idempotency_key_in_progress | The same key is still being processed | Release, same key |
| 413 | file_too_large | PDF is over 10 MB | Permanent |
| 422 | invalid_pdf | Not a PDF, corrupt, password-protected, or analysis hit its limit | Permanent |
| 422 | idempotency_key_fingerprint_mismatch | Key reused with a different URL | Permanent: new key per document |
| 429 | server_at_capacity | Server-wide concurrency limit, with a Retry-After header | Release for Retry-After |
| 500 | internal_error | Unexpected server error | Rethrow; backoff retries |
The 429 is server-wide capacity, not a per-key rate limit, so the same handling applies to test and live keys. A password-protected PDF has to be unlocked before submission; the API does not accept encrypted files.
Step 9: Test with Pest, offline
Two layers: fakes for everything that runs in CI, and a thin smoke test against the real API with the test key.
Unit tests with Http::fake()
Http::preventStrayRequests() turns any unfaked request into an exception, so a test can never reach the network by accident. Sleep::fake() makes the client’s retry delays instant.
tests/Feature/HTPBE/VerifyDocumentJobTest.php
<?php
use App\Enums\VerificationState;
use App\Jobs\VerifyDocumentJob;
use App\Models\Document;
use App\Services\HTPBE\HTPBEClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Sleep;
const CHECK_ID = '00000000-0000-4000-8000-000000000005';
function resultPayload(string $status, array $overrides = []): array
{
return array_merge([
'id' => CHECK_ID,
'filename' => 'statement.pdf',
'algorithm_version' => '2.44.5',
'status' => $status,
'creator' => null,
'producer' => null,
'modification_markers' => [],
'usage_caution' => [
'safe_for_automated_adverse_decision' => false,
'recommended_action' => 'no_action',
'message' => '…',
],
], $overrides);
}
beforeEach(function () {
Sleep::fake();
Http::preventStrayRequests();
config(['services.htpbe.api_key' => 'htpbe_test_fake']);
app()->forgetInstance(HTPBEClient::class);
Storage::shouldReceive('disk->temporaryUrl')->andReturn('https://files.example.test/doc.pdf');
});
it('sends a modified document to human review', function () {
Http::fake([
'api.htpbe.tech/v1/analyze' => Http::response(['id' => CHECK_ID], 201),
'api.htpbe.tech/v1/result/*' => Http::response(resultPayload('modified', [
'modification_markers' => ['HTPBE_EDITING_TOOL_FINGERPRINT'],
'usage_caution' => ['safe_for_automated_adverse_decision' => false, 'recommended_action' => 'route_to_human_review', 'message' => '…'],
])),
]);
$document = Document::factory()->create();
(new VerifyDocumentJob($document))->handle(app(HTPBEClient::class));
$document->refresh();
expect($document->verification_state)->toBe(VerificationState::HumanReview)
->and($document->htpbe_markers)->toBe(['HTPBE_EDITING_TOOL_FINGERPRINT'])
->and($document->htpbe_check_id)->toBe(CHECK_ID)
->and($document->htpbe_submission_url)->toBeNull();
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/analyze')
&& $request->hasHeader('Idempotency-Key'));
});
it('does not submit again when the check id is already stored', function () {
Http::fake(['api.htpbe.tech/v1/result/*' => Http::response(resultPayload('intact'))]);
$document = Document::factory()->create(['htpbe_check_id' => CHECK_ID]);
(new VerifyDocumentJob($document))->handle(app(HTPBEClient::class));
expect($document->refresh()->verification_state)->toBe(VerificationState::Cleared);
Http::assertSentCount(1);
});
it('releases the job for the Retry-After delay on 429', function () {
Http::fake([
'api.htpbe.tech/v1/analyze' => Http::response(
['error' => 'Server is at analysis capacity, retry shortly', 'code' => 'server_at_capacity'],
429,
['Retry-After' => '7'],
),
]);
$job = new VerifyDocumentJob(Document::factory()->create());
$job->withFakeQueueInteractions();
$job->handle(app(HTPBEClient::class));
$job->assertReleased(delay: 7);
});
it('fails permanently and records the code on 402', function () {
Http::fake([
'api.htpbe.tech/v1/analyze' => Http::response(
['error' => 'No credits remaining.', 'code' => 'payment_required'],
402,
),
]);
$document = Document::factory()->create();
$job = new VerifyDocumentJob($document);
$job->withFakeQueueInteractions();
$job->handle(app(HTPBEClient::class));
$job->assertFailed();
expect($document->refresh())
->verification_state->toBe(VerificationState::Failed)
->htpbe_error_code->toBe('payment_required');
});
it('retries a transient 500 and then succeeds', function () {
Http::fake([
'api.htpbe.tech/v1/analyze' => Http::sequence()
->push(['error' => 'Failed to analyze PDF', 'code' => 'internal_error'], 500)
->push(['id' => CHECK_ID], 201),
'api.htpbe.tech/v1/result/*' => Http::response(resultPayload('intact')),
]);
$document = Document::factory()->create();
(new VerifyDocumentJob($document))->handle(app(HTPBEClient::class));
expect($document->refresh()->verification_state)->toBe(VerificationState::Cleared);
});The retry test works because the client sends the same Idempotency-Key on both attempts — add Http::assertSent on the header value if you want that pinned down.
Smoke test against the real API
This is the only test that needs the network, and it uses only the test key and the mock URLs, so it spends no credits. Keep it out of the default suite and run it before releases or when you bump the API client.
tests/Integration/HTPBESandboxTest.php
<?php
use App\Services\HTPBE\HTPBEClient;
use App\Services\HTPBE\HTPBEException;
use App\Services\HTPBE\HTPBEStatus;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Str;
function sandboxClient(): HTPBEClient
{
return new HTPBEClient(app(Factory::class), (string) env('HTPBE_TEST_API_KEY'), 'https://api.htpbe.tech/v1', 60);
}
beforeEach(function () {
if (! env('HTPBE_TEST_API_KEY')) {
$this->markTestSkipped('HTPBE_TEST_API_KEY is not set');
}
});
it('maps the documented mock verdicts', function (string $file, HTPBEStatus $status, ?string $reason) {
$client = sandboxClient();
$id = $client->submit("https://api.htpbe.tech/v1/test/{$file}", (string) Str::uuid());
$result = $client->getResult($id);
expect($result->status)->toBe($status)->and($result->statusReason)->toBe($reason);
})->with([
['clean.pdf', HTPBEStatus::Intact, null],
['modified-high.pdf', HTPBEStatus::Modified, null],
['inconclusive.pdf', HTPBEStatus::Inconclusive, 'consumer_software_origin'],
['scanned-document.pdf', HTPBEStatus::Inconclusive, 'scanned_document'],
]);
it('surfaces the out-of-credits path', function () {
sandboxClient()->submit('https://api.htpbe.tech/v1/test/trigger-402.pdf', (string) Str::uuid());
})->throws(HTPBEException::class, exceptionCode: 402);trigger-402.pdf is the documented way to exercise the out-of-credits response with a test key, because a test key never debits anything. Mock results are deterministic: check_date is always null on them, so do not assert on it. The full list of mock URLs, including graduated modification levels and signature scenarios, is in the testing reference.
Step 10: Backfills, Stalled Documents and the Top-Up Recovery
The upload path in Step 7 handles documents one at a time. Three operational cases need Laravel features the job does not use yet: checking the documents that were already in the system before you integrated, catching documents the queue silently gave up on, and recovering after a 402.
Backfill with a batch that cancels itself
Bus::batch() gives you a progress counter and a kill switch, and the kill switch matters here: payment_required means the credit pool is empty, so the other 40,000 jobs would each burn a worker slot to learn the same thing. Add the Batchable trait to VerifyDocumentJob and two lines to it:
use Illuminate\Bus\Batchable;
final class VerifyDocumentJob implements ShouldQueue
{
use Batchable; // alongside the traits already there
public function handle(HTPBEClient $client): void
{
if ($this->batch()?->cancelled()) {
return; // the batch was stopped; the document stays pending
}
// ... unchanged ...
}
private function handleApiError(Document $document, HTPBEException $e): void
{
// ... the capacity branch is unchanged ...
if ($e->errorCode === 'payment_required' && $this->batch() !== null) {
$this->batch()->cancel();
$document->update(['htpbe_error_code' => $e->errorCode]); // state stays Pending
return; // the job ends quietly: the file was never judged
}
// ... the existing isPermanent() branch is unchanged ...
}
}The new branch deliberately leaves the document pending instead of failed: nothing is wrong with the file, and a pending row is picked up by the next backfill run without a manual reset. Outside a batch, batch() returns null, so the live upload path keeps the Step 7 behaviour. Also add 'htpbe_error_code' => null to the success update() in handle(), so a document that completes on a later run does not keep a stale code.
The command that builds the batch loads at most --limit documents, which is what bounds its memory:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\VerificationState;
use App\Jobs\VerifyDocumentJob;
use App\Models\Document;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Bus;
final class BackfillVerifications extends Command
{
protected $signature = 'htpbe:backfill {--limit=1000 : Maximum documents to enqueue}';
protected $description = 'Verify documents stored before the integration existed';
public function handle(): int
{
$jobs = Document::query()
->where('verification_state', VerificationState::Pending)
->whereNull('htpbe_check_id')
->where(fn ($q) => $q
->whereNull('verification_requested_at') // never queued
->orWhereNotNull('htpbe_error_code')) // parked by a cancelled batch
->orderBy('id')
->limit((int) $this->option('limit'))
->get()
->each->forceFill(['verification_requested_at' => now(), 'htpbe_error_code' => null])
->each->save()
->map(fn (Document $d) => new VerifyDocumentJob($d))
->all();
$batch = Bus::batch($jobs)
->name('htpbe-backfill')
->onQueue('verification')
->allowFailures()
->dispatch();
$this->info("Batch {$batch->id}: {$batch->totalJobs} jobs queued.");
return self::SUCCESS;
}
}Two cautions. First, the command stamps each document with verification_requested_at (the column is added just below), because a backfilled row is months old and a deadline counted from created_at would have expired before the first attempt. Second, keep --limit modest at first: run 20 documents, read the results, and only then raise it, because each one spends a credit.
Add the column, set it wherever a document is queued, and key the job’s deadline off it:
Schema::table('documents', function (Blueprint $table) {
$table->timestamp('verification_requested_at')->nullable();
});
// VerifyDocumentJob
public function retryUntil(): \DateTimeInterface
{
return ($this->document->verification_requested_at ?? $this->document->created_at)
->addMinutes(25);
}In the upload endpoint from Step 7, add 'verification_requested_at' => now() to the Document::create([...]) call, and in resetForRetry() add 'verification_requested_at' => now() to the forceFill(). That also settles the deadline note after resetForRetry(): the 25 minutes now count from the latest request, not from the upload.
Documents the queue gave up on
When retryUntil() passes while a job is still being retried, Laravel marks the job failed with MaxAttemptsExceededException, and your handleApiError never runs. The row is then pending forever, and nothing in the UI says why. A scheduled sweep closes that gap. In routes/console.php:
use App\Enums\VerificationState;
use App\Models\Document;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
Document::query()
->where('verification_state', VerificationState::Pending)
->whereNull('htpbe_check_id')
->whereNull('htpbe_error_code') // batch-cancelled rows wait for the next backfill
->whereNotNull('verification_requested_at') // rows nobody has queued yet are not stalled
->where('verification_requested_at', '<', now()->subMinutes(40)) // past retryUntil plus slack
->update([
'verification_state' => VerificationState::Failed,
'htpbe_error_code' => 'stalled',
]);
})->everyTenMinutes()->name('htpbe-stalled-sweep')->onOneServer();Only documents with no check id are swept. One that already has an id was submitted and charged, and its next attempt only re-reads the result, which costs nothing; sweeping it would throw away a paid answer. Rows parked by a cancelled batch carry an error code, so the sweep leaves them for the next backfill run.
Recovery after a top-up
After you buy credits, documents that failed with payment_required in the live path (the non-batch one) can be re-queued; rows parked by a cancelled batch are covered by running htpbe:backfill again with the resetForRetry() method from Step 7:
Document::query()
->where('verification_state', VerificationState::Failed)
->where('htpbe_error_code', 'payment_required')
->chunkById(100, fn ($docs) => $docs->each->resetForRetry());Put that behind an Artisan command with a confirmation prompt rather than a schedule. A top-up is a decision a person makes, and an automatic retry loop against an empty pool is exactly the behaviour the batch cancel above exists to prevent.
Reviewing past checks
GET /checks returns your own checks, newest first, as {data, total, limit, offset, has_more}, filterable by status, from_date, to_date, creator, producer and tool (limit goes up to 500). It suits a scheduled reconciliation: list the modified checks and compare against your documents table.
$page = $client->listChecks(HTPBEStatus::Modified, limit: 100);
foreach ($page['data'] as $check) {
// $check['id'], $check['filename'], $check['check_date'], $check['usage_caution'], …
}Test keys always get an empty data array here, since mock checks are never stored.
Security notes specific to this integration
- The signed URL is a bearer token. Anyone who has it can download the file until it expires. The job stores it encrypted, clears it once a verdict is in, and the 30-minute lifetime outlasts the job’s 25-minute retry deadline, so a retry never sends an expired link. Do not log it or put it in exception context.
- Mint URLs yourself. The API fetches whatever URL it is given. Build the URL from your own storage on the server; never forward a user-supplied link into
POST /analyze. - Credits are one pool. Subscription quota, one-time credit packs and welcome credits draw from the same balance, shared with web uploads. Alert on
payment_requiredrather than letting verification quietly stop. Plans and credit packs are on the pricing page. - Don’t store more than you need.
status,status_reason, the marker ids, the check id andalgorithm_versionare enough to explain a decision later; the full result can always be re-read withGET /result/{id}.
What this does not catch
A structural check reads how the file was built and changed. It does not validate cryptographic signatures (it detects their presence, removal and edits after signing), it does not read or compare the visible content, and it cannot say a document was made honestly from scratch. Treat intact as “no structural evidence of modification”, and keep the issuer-side checks that need the content.
Where to go from here
Everything above is plain Laravel, so the pieces are replaceable: swap the S3 disk for any storage that can produce a temporary URL, move the job onto Horizon, or raise the retry window for slower queues. There is no official PHP package on Packagist; the API is small enough that the client in Step 5 is the right size for most apps, and the Python client exists if you want a second reference. The changelog lists algorithm versions, which you store per document in htpbe_algorithm_version.
To run it for real, create a key on the API page, start with the test key and the mock URLs, and pick a plan on the pricing page when you move to live documents.