Laravel
Validate documents in Laravel 11 and 12 with constaia/constaia-php: facade, client injection, your own validation rule, queued job, webhook and widget.
The constaia/constaia-php PHP SDK ships a Laravel integration: a service provider that registers Constaia\Client
as a singleton and the Constaia facade, both through auto-discovery. This guide covers the synchronous flow in a
controller, your own validation rule, a queued job for background processing, the signed webhook and the
widget in Blade.
Requirements
- Laravel 11 or 12 (PHP ≥ 8.2). The SDK works with PHP ≥ 8.1,
ext-curlandext-json. - A test key
ck_test_…from the dashboard. In test mode no credits are consumed and the result depends on the file name. - For the queued version, a configured queue driver (
database,redis…).
Installation
composer require constaia/constaia-phpLaravel discovers the package automatically: it registers Constaia\Laravel\ConstaiaServiceProvider (singleton
Constaia\Client, alias constaia) and the Constaia facade (Constaia\Laravel\Facades\Constaia). To edit the
configuration, publish it:
php artisan vendor:publish --tag=constaia-configThat creates config/constaia.php with api_key, base_url, timeout, max_retries and webhook_secret, all read
from the environment.
Environment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
# CONSTAIA_TIMEOUT=60
# CONSTAIA_MAX_RETRIES=2If you cache the configuration (php artisan config:cache), run it again after changing .env. The key stays on the
server: never pass it to a view or to JavaScript.
Controller with dependency injection
Inject Constaia\Client into the method. Pass the UploadedFile as is: the SDK uses the client's original name and
MIME type, so test mode just works. Your server decides expect and checks.
<?php
namespace App\Http\Controllers;
use Constaia\Client;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InvalidRequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class IdentityDocumentController extends Controller
{
public function store(Request $request, Client $constaia): RedirectResponse
{
$request->validate([
'dni' => ['required', 'file', 'max:20480', 'mimes:jpg,jpeg,png,webp,heic,pdf'],
]);
$user = $request->user();
try {
$analysis = $constaia->analyze($request->file('dni'), [
'expect' => ['es_dni', 'es_nie', 'passport'],
'checks' => [
'not_expired' => true,
'min_age_years' => 18,
'holder' => ['full_name' => $user->name],
],
'storage' => 'none',
'language' => in_array(app()->getLocale(), ['es', 'en', 'pt', 'fr'], true) ? app()->getLocale() : 'es',
'metadata' => ['user_id' => (string) $user->id],
]);
} catch (InvalidRequestException $e) {
return back()->withErrors(['dni' => match ($e->errorCode) {
'file_too_large' => 'The file is larger than 20 MB.',
'unreadable_image', 'unreadable_pdf' => 'We cannot read the document. Try another photo.',
default => 'The document could not be processed.',
}]);
} catch (ConstaiaException $e) {
report($e);
return back()->withErrors(['dni' => 'We could not check the document. Please try again later.']);
}
if ($analysis->isInvalid()) {
$errors = collect($analysis->verdict->reasons)
->where('severity', 'error')
->pluck('message')
->all();
return back()->withErrors(['dni' => $errors]);
}
$user->identityDocuments()->create([
'constaia_id' => $analysis->id,
'status' => $analysis->verdictStatus() ?? $analysis->status, // valid | review | queued…
'document_number' => $analysis->field('document_number') ?? $analysis->field('nie_number'),
]);
return back()->with('status', $analysis->needsReview()
? 'We will review it manually.'
: 'Document verified.');
}
}use App\Http\Controllers\IdentityDocumentController;
Route::post('/identity-document', [IdentityDocumentController::class, 'store'])
->middleware(['auth', 'throttle:10,1']);If the analysis takes longer than 30 s the API answers 202 and $analysis->status is queued or processing: the
result arrives through the webhook below. $analysis->verdictStatus() returns valid, invalid, review or null.
With the facade
The facade exposes analyze, classify, balance and usage:
use Constaia\Laravel\Facades\Constaia;
$analysis = Constaia::analyze($request->file('dni'), [
'expect' => 'es_dni',
'checks' => ['not_expired' => true],
]);
$analysis->isValid(); // bool
$analysis->field('birth_date'); // "1990-05-14"For the other resources (analyses, batches, webhookEndpoints…) use the injected client or
app(Constaia\Client::class).
Your own validation rule
The package does not ship validation rules, but writing one is simple. This rule is your application's code: it calls Constaia and fails with the reasons returned by the API.
Every validation is a paid call
The rule makes an API request while Laravel validates the form: with a live key it consumes credits every time it
runs, including when the form fails on another field and the user resubmits. Put it last, with bail, and protect the
route with authentication and throttle.
<?php
namespace App\Rules;
use Closure;
use Constaia\Analysis;
use Constaia\Client;
use Constaia\Exception\ConstaiaException;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;
class ValidDocument implements ValidationRule
{
public ?Analysis $analysis = null;
/**
* @param string|list<string> $expect
* @param array<string, mixed> $checks
*/
public function __construct(
private string|array $expect,
private array $checks = [],
private bool $acceptReview = true,
) {
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!$value instanceof UploadedFile) {
$fail('Upload a file.');
return;
}
try {
$this->analysis = app(Client::class)->analyze($value, [
'expect' => $this->expect,
'checks' => $this->checks,
'storage' => 'none',
'language' => in_array(app()->getLocale(), ['es', 'en', 'pt', 'fr'], true) ? app()->getLocale() : 'es',
]);
} catch (ConstaiaException $e) {
report($e);
$fail('We could not check the document. Please try again later.');
return;
}
if ($this->analysis->isInvalid()) {
foreach ($this->analysis->verdict->reasons as $reason) {
if ($reason->severity === 'error') {
$fail($reason->message);
}
}
} elseif ($this->analysis->needsReview() && !$this->acceptReview) {
$fail('The image is not clear enough. Upload another photo.');
}
}
}Keep the instance to reuse the analysis in the controller instead of calling twice:
<?php
namespace App\Http\Controllers;
use App\Rules\ValidDocument;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MedicalCertificateController extends Controller
{
public function store(Request $request): RedirectResponse
{
$rule = new ValidDocument('medical_certificate_sport', [
'max_age_days' => 365,
'require_signature' => true,
'require_stamp' => true,
'holder' => ['full_name' => $request->user()->name],
]);
$request->validate([
'certificate' => ['bail', 'required', 'file', 'max:20480', 'mimes:jpg,jpeg,png,webp,heic,pdf', $rule],
]);
$request->user()->medicalCertificates()->create([
'constaia_id' => $rule->analysis->id,
'status' => $rule->analysis->verdictStatus(),
'issue_date' => $rule->analysis->field('issue_date'),
]);
return back()->with('status', 'Certificate received.');
}
}More on medical certificates in Sports medical certificate.
Background processing with a job
To avoid blocking the request (or when the user uploads several documents), store the file temporarily and analyse it
in a job. The job uses an idempotency_key derived from the record: if it is retried with the same file within 24 h,
the API returns the stored response and does not charge twice (see Idempotency).
<?php
namespace App\Http\Controllers;
use App\Jobs\AnalyzeDocument;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DocumentUploadController extends Controller
{
public function store(Request $request): JsonResponse
{
$request->validate(['document' => ['required', 'file', 'max:20480', 'mimes:jpg,jpeg,png,webp,heic,pdf']]);
$file = $request->file('document');
$document = $request->user()->documents()->create(['status' => 'pending']);
$path = $file->store('constaia-tmp', 'local');
AnalyzeDocument::dispatch($document->id, $path, $file->getClientOriginalName());
return response()->json(['id' => $document->id, 'status' => 'pending'], 202);
}
}<?php
namespace App\Jobs;
use App\Models\Document;
use Constaia\Client;
use Constaia\Exception\RateLimitException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
use Throwable;
class AnalyzeDocument implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 120;
public function __construct(
public int $documentId,
public string $path,
public string $originalName,
) {
}
public function backoff(): array
{
return [10, 60, 300, 900];
}
public function handle(Client $constaia): void
{
$document = Document::findOrFail($this->documentId);
try {
$analysis = $constaia->analyze(Storage::disk('local')->path($this->path), [
'filename' => $this->originalName,
'idempotency_key' => "document-{$document->id}",
'expect' => ['es_dni', 'es_nie', 'passport'],
'checks' => ['not_expired' => true],
'storage' => 'none',
'metadata' => ['document_id' => (string) $document->id],
]);
} catch (RateLimitException $e) {
$this->release($e->retryAfter ?? 10);
return;
}
$document->update([
'constaia_id' => $analysis->id,
'status' => $analysis->verdictStatus() ?? $analysis->status,
'reasons' => $analysis->verdict?->toArray()['reasons'] ?? [],
]);
Storage::disk('local')->delete($this->path);
}
public function failed(?Throwable $e): void
{
Document::whereKey($this->documentId)->update(['status' => 'error']);
Storage::disk('local')->delete($this->path);
}
}Other exceptions (ApiException, ConnectionException, InvalidRequestException…) are not caught: the job fails
and Laravel retries it following backoff() up to $tries; the SDK has already done its own retries before that. If
you'd rather not retry an InvalidRequestException (unreadable file, unsupported type…), catch it and call
$this->fail($e). The Queueable trait from Illuminate\Foundation\Queue is the Laravel 11+ one; in older projects
the job generated by make:job uses several equivalent traits.
For long documents or high volume, another option is 'async' => true: the API answers 202 right away and the result
arrives at the webhook.
Webhook
Create the endpoint in the dashboard (or with $constaia->webhookEndpoints->create([...])) pointing to
https://your-domain.com/webhooks/constaia and store the secret in CONSTAIA_WEBHOOK_SECRET. Verify the signature
with the raw body: $request->getContent().
<?php
namespace App\Http\Controllers;
use App\Jobs\HandleConstaiaEvent;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
class ConstaiaWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
try {
$event = Webhook::verify(
$request->getContent(),
$request->headers->all(),
(string) config('constaia.webhook_secret'),
);
} catch (SignatureVerificationException) {
abort(400, 'Invalid signature');
}
// webhook-id is the same across retries: process each event only once.
if (Cache::add('constaia:webhook:' . $request->header('webhook-id'), true, now()->addDays(4))) {
HandleConstaiaEvent::dispatch($event);
}
return response()->noContent();
}
}<?php
namespace App\Jobs;
use App\Models\Document;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class HandleConstaiaEvent implements ShouldQueue
{
use Queueable;
public function __construct(public array $event)
{
}
public function handle(): void
{
$data = $this->event['data'];
match ($this->event['type']) {
'analysis.completed', 'analysis.review_required', 'analysis.failed' => Document::where('constaia_id', $data['id'])
->update(['status' => $data['verdict']['status'] ?? $data['status']]),
'batch.completed' => Log::info('Constaia batch completed', ['id' => $data['id'], 'counts' => $data['counts']]),
'credits.low' => Log::warning('Constaia credits low', ['available' => $data['credits_available']]),
default => null,
};
}
}Register the route and exclude it from CSRF protection in bootstrap/app.php (Laravel 11 and 12):
use App\Http\Controllers\ConstaiaWebhookController;
Route::post('/webhooks/constaia', ConstaiaWebhookController::class);->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/constaia',
]);
})If you define it in routes/api.php it does not go through CSRF and needs no exception. The route has no auth: the
signature is the authentication. Answer 2xx within 15 s; heavy work goes to the job. Format and retries in
Webhooks.
Widget in Blade
The <constaia-upload> widget captures the document in the browser and sends it to your route,
without a key. Pass the CSRF token in the headers attribute: the route keeps normal CSRF protection.
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload
endpoint="{{ route('constaia.analyze') }}"
document="es_dni"
lang="{{ app()->getLocale() }}"
headers='@json(['X-CSRF-TOKEN' => csrf_token()])'
></constaia-upload>
<script>
document.querySelector('constaia-upload').addEventListener('constaia:result', (event) => {
if (event.detail.verdict?.status !== 'invalid') window.location.href = '/next-step';
});
</script>use App\Http\Controllers\ConstaiaWidgetController;
Route::post('/constaia/analyze', ConstaiaWidgetController::class)
->middleware(['auth', 'throttle:10,1'])
->name('constaia.analyze');<?php
namespace App\Http\Controllers;
use Constaia\Client;
use Constaia\Exception\ConstaiaException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ConstaiaWidgetController extends Controller
{
public function __invoke(Request $request, Client $constaia): JsonResponse
{
$request->validate(['file' => ['required', 'file', 'max:20480', 'mimes:jpg,jpeg,png,webp,heic,pdf']]);
// Only the language comes from the browser; the server sets expect and checks.
$fromBrowser = json_decode((string) $request->input('options'), true) ?: [];
$language = in_array($fromBrowser['language'] ?? null, ['es', 'en', 'pt', 'fr'], true) ? $fromBrowser['language'] : 'es';
try {
$analysis = $constaia->analyze($request->file('file'), [
'expect' => ['es_dni', 'es_nie', 'passport'],
'checks' => ['not_expired' => true, 'min_age_years' => 18],
'storage' => 'none',
'language' => $language,
'metadata' => ['user_id' => (string) $request->user()->id],
]);
} catch (ConstaiaException $e) {
report($e);
return response()->json(['error' => ['message' => 'We could not check the document.']], 502);
}
return response()->json([
'id' => $analysis->id,
'object' => 'analysis',
'status' => $analysis->status,
'document' => $analysis->document?->toArray(),
'verdict' => $analysis->verdict?->toArray(),
'warnings' => $analysis->warnings ?? [],
]);
}
}Return only what the browser needs: the widget renders verdict.status, verdict.reasons[].message and warnings.
Extracted fields stay on your server.
Errors
Every SDK exception extends Constaia\Exception\ConstaiaException and exposes errorCode, param, requestId and
httpStatus (also via getErrorCode(), getRequestId()…).
| Exception | HTTP | What to do |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 | Look at errorCode (file_too_large, unsupported_file_type, unreadable_image, invalid_parameter…); ask for another file or fix the option |
AuthenticationException | 401 | Missing or revoked key; check CONSTAIA_API_KEY and config:cache |
InsufficientCreditsException | 402 | Top up credits in the dashboard |
PermissionException | 403 | The key is not allowed to do that |
NotFoundException | 404 | Analysis deleted, from another account or created with keep_results: false |
RateLimitException | 429 | Already retried by the SDK; retryAfter says how long to wait |
ApiException | 5xx | Already retried; 503 live_mode_unavailable means live mode is not available, use ck_test_ |
ConnectionException | — | Network or timeout after the retries |
The SDK retries 429, 5xx and network errors (max_retries, 2 by default) honouring Retry-After and with the same
Idempotency-Key. Keep requestId in your logs. Full list of codes in Errors.
Tests
With CONSTAIA_API_KEY=ck_test_... in .env.testing the API answers deterministically based on the file name and
charges nothing. UploadedFile::fake()->image() generates a real JPEG (it needs the GD extension).
<?php
namespace Tests\Feature;
use App\Models\User;
use Constaia\Webhook;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class IdentityDocumentTest extends TestCase
{
use RefreshDatabase;
public function test_valid_dni_is_accepted(): void
{
$user = User::factory()->create(['name' => 'María García López']);
$this->actingAs($user)
->post('/identity-document', ['dni' => UploadedFile::fake()->image('dni_valid.jpg', 800, 500)])
->assertSessionHasNoErrors();
$this->assertDatabaseHas('identity_documents', ['user_id' => $user->id, 'status' => 'valid', 'document_number' => '12345678Z']);
}
public function test_expired_dni_is_rejected(): void
{
app()->setLocale('es');
$user = User::factory()->create(['name' => 'Juan Pérez Sánchez']);
$this->actingAs($user)
->post('/identity-document', ['dni' => UploadedFile::fake()->image('dni_expired.jpg', 800, 500)])
->assertSessionHasErrors(['dni' => 'Caducado el 15/06/2020.']);
}
public function test_webhook_rejects_bad_signature_and_accepts_good_one(): void
{
Queue::fake();
$payload = json_encode(['type' => 'analysis.completed', 'created_at' => now()->toIso8601String(), 'data' => ['id' => 'an_test', 'status' => 'completed']]);
$headers = Webhook::headers($payload, config('constaia.webhook_secret'));
$this->call('POST', '/webhooks/constaia', [], [], [], $this->transformHeadersToServerVars(['webhook-signature' => 'v1,bad'] + $headers), $payload)
->assertStatus(400);
$this->call('POST', '/webhooks/constaia', [], [], [], $this->transformHeadersToServerVars($headers), $payload)
->assertNoContent();
Queue::assertPushed(\App\Jobs\HandleConstaiaEvent::class);
}
}test_expired_dni_is_rejected uses a holder that matches the test ID card (Juan Pérez Sánchez) so that the only error
is the expiry, and sets the es locale so the message is in Spanish. With dni_valid.jpg and a different name, the
holder reason would also be an error. Files and results in Test mode.
Production checklist
php.ini:upload_max_filesize = 20M,post_max_size = 21M,max_execution_time≥ 90 on the synchronous route.- nginx
client_max_body_size 21M;andfastcgi_read_timeout 90s;; load balancer timeout ≥ 60 s. Or move the analysis to a job (jobtimeoutabove 60 s and the queue connection'sretry_aftergreater than thattimeout). - Upload routes with
authandthrottle: every live analysis consumes credits. expectandchecksfixed on the server; from the widget you only acceptlanguage.CONSTAIA_API_KEY=ck_live_...only in production;php artisan config:cacheafter changing it.- Webhook excluded from CSRF, with a verified signature, deduplicated on
webhook-idand processed in a queue. - Choose
storageaccording to your obligations: Storage and privacy.
Next steps
PHP
Validate documents from plain PHP 8.1+ with the constaia/constaia-php SDK: a $_FILES upload form, a signed webhook and a no-SDK cURL CURLFile variant.
Symfony
Validate documents in Symfony 6.4 and 7 with constaia/constaia-php, with the client as a service, UploadedFile, Messenger for async work and a signed webhook.