Constaia
Integrations

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-curl and ext-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-php

Laravel 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-config

That creates config/constaia.php with api_key, base_url, timeout, max_retries and webhook_secret, all read from the environment.

Environment variables

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
# CONSTAIA_TIMEOUT=60
# CONSTAIA_MAX_RETRIES=2

If 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.

app/Http/Controllers/IdentityDocumentController.php
<?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.');
    }
}
routes/web.php
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.

app/Rules/ValidDocument.php
<?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:

app/Http/Controllers/MedicalCertificateController.php
<?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).

app/Http/Controllers/DocumentUploadController.php
<?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);
    }
}
app/Jobs/AnalyzeDocument.php
<?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().

app/Http/Controllers/ConstaiaWebhookController.php
<?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();
    }
}
app/Jobs/HandleConstaiaEvent.php
<?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):

routes/web.php
use App\Http\Controllers\ConstaiaWebhookController;

Route::post('/webhooks/constaia', ConstaiaWebhookController::class);
bootstrap/app.php
->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.

resources/views/identity/upload.blade.php
<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>
routes/web.php
use App\Http\Controllers\ConstaiaWidgetController;

Route::post('/constaia/analyze', ConstaiaWidgetController::class)
    ->middleware(['auth', 'throttle:10,1'])
    ->name('constaia.analyze');
app/Http/Controllers/ConstaiaWidgetController.php
<?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()…).

ExceptionHTTPWhat to do
InvalidRequestException400, 409, 413, 415, 422Look at errorCode (file_too_large, unsupported_file_type, unreadable_image, invalid_parameter…); ask for another file or fix the option
AuthenticationException401Missing or revoked key; check CONSTAIA_API_KEY and config:cache
InsufficientCreditsException402Top up credits in the dashboard
PermissionException403The key is not allowed to do that
NotFoundException404Analysis deleted, from another account or created with keep_results: false
RateLimitException429Already retried by the SDK; retryAfter says how long to wait
ApiException5xxAlready 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).

tests/Feature/IdentityDocumentTest.php
<?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; and fastcgi_read_timeout 90s;; load balancer timeout ≥ 60 s. Or move the analysis to a job (job timeout above 60 s and the queue connection's retry_after greater than that timeout).
  • Upload routes with auth and throttle: every live analysis consumes credits.
  • expect and checks fixed on the server; from the widget you only accept language.
  • CONSTAIA_API_KEY=ck_live_... only in production; php artisan config:cache after changing it.
  • Webhook excluded from CSRF, with a verified signature, deduplicated on webhook-id and processed in a queue.
  • Choose storage according to your obligations: Storage and privacy.

Next steps

On this page