Constaia
Integraciones

Laravel

Valida documentos en Laravel 11 y 12 con constaia/constaia-php: facade, inyección del cliente, regla de validación propia, job en cola, webhook y widget.

El SDK de PHP constaia/constaia-php trae integración con Laravel: un service provider que registra Constaia\Client como singleton y la facade Constaia, ambos por auto-discovery. Esta guía cubre el flujo síncrono en un controlador, una regla de validación propia, un job en cola para procesar en segundo plano, el webhook firmado y el widget en Blade.

Requisitos

  • Laravel 11 o 12 (PHP ≥ 8.2). El SDK funciona con PHP ≥ 8.1, ext-curl y ext-json.
  • Una clave de test ck_test_… del panel. En modo test no se consumen créditos y el resultado depende del nombre del fichero.
  • Para la versión en cola, un driver de colas configurado (database, redis…).

Instalación

composer require constaia/constaia-php

Laravel descubre el paquete automáticamente: registra Constaia\Laravel\ConstaiaServiceProvider (singleton Constaia\Client, alias constaia) y la facade Constaia (Constaia\Laravel\Facades\Constaia). Si quieres editar la configuración, publícala:

php artisan vendor:publish --tag=constaia-config

Eso crea config/constaia.php con api_key, base_url, timeout, max_retries y webhook_secret, todos leídos del entorno.

Variables de entorno

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

Si cacheas la configuración (php artisan config:cache), vuelve a ejecutarlo tras cambiar el .env. La clave se queda en el servidor: nunca la pases a una vista ni a JavaScript.

Controlador con inyección de dependencias

Inyecta Constaia\Client en el método. UploadedFile se pasa tal cual: el SDK usa el nombre y el tipo MIME originales del cliente, así que el modo test funciona sin más. expect y checks los decide el servidor.

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' => 'El archivo supera los 20 MB.',
                'unreadable_image', 'unreadable_pdf' => 'No se puede leer el documento. Prueba con otra foto.',
                default => 'El documento no se ha podido procesar.',
            }]);
        } catch (ConstaiaException $e) {
            report($e);

            return back()->withErrors(['dni' => 'No hemos podido comprobar el documento. Inténtalo más tarde.']);
        }

        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()
            ? 'Lo revisaremos manualmente.'
            : 'Documento verificado.');
    }
}
routes/web.php
use App\Http\Controllers\IdentityDocumentController;

Route::post('/identity-document', [IdentityDocumentController::class, 'store'])
    ->middleware(['auth', 'throttle:10,1']);

Si el análisis tarda más de 30 s la API responde 202 y $analysis->status es queued o processing: el resultado llega por el webhook de más abajo. $analysis->verdictStatus() devuelve valid, invalid, review o null.

Con la facade

La facade expone analyze, classify, balance y 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"

Para el resto de recursos (analyses, batches, webhookEndpoints…) usa el cliente inyectado o app(Constaia\Client::class).

Regla de validación propia

El paquete no incluye reglas de validación, pero escribir una es sencillo. Esta regla es código de tu aplicación: llama a Constaia y falla con los motivos que devuelve la API.

Cada validación es una llamada de pago

La regla hace una petición a la API mientras Laravel valida el formulario: con clave live consume créditos cada vez que se ejecuta, también si el formulario falla por otro campo y el usuario reenvía. Ponla la última, con bail, y protege la ruta con autenticación y 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('Sube un archivo.');

            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('No hemos podido comprobar el documento. Inténtalo más tarde.');

            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('La imagen no es lo bastante clara. Sube otra foto.');
        }
    }
}

Guarda la instancia para reutilizar el análisis en el controlador y no llamar dos veces:

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', 'Certificado recibido.');
    }
}

Más sobre certificados médicos en Certificado médico deportivo.

Procesar en segundo plano con un job

Para no bloquear la petición (o si el usuario sube varios documentos), guarda el fichero temporalmente y analízalo en un job. El job usa una idempotency_key derivada del registro: si se reintenta con el mismo fichero en 24 h, la API devuelve la respuesta guardada y no cobra dos veces (ver Idempotencia).

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);
    }
}

Las demás excepciones (ApiException, ConnectionException, InvalidRequestException…) no se capturan: el job falla y Laravel lo reintenta según backoff() hasta $tries; el SDK ya ha hecho sus propios reintentos antes. Si prefieres no reintentar una InvalidRequestException (fichero ilegible, tipo no admitido…), captúrala y llama a $this->fail($e). El trait Queueable de Illuminate\Foundation\Queue es el de Laravel 11+; en proyectos antiguos el job generado por make:job usa varios traits equivalentes.

Para documentos largos o volumen alto, otra opción es 'async' => true: la API responde 202 al momento y el resultado llega al webhook.

Webhook

Crea el endpoint en el panel (o con $constaia->webhookEndpoints->create([...])) apuntando a https://tu-dominio.com/webhooks/constaia y guarda el secret en CONSTAIA_WEBHOOK_SECRET. Verifica la firma con el cuerpo crudo: $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 es el mismo en todos los reintentos: procesa cada evento una sola vez.
        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,
        };
    }
}

Registra la ruta y exclúyela de la protección CSRF en bootstrap/app.php (Laravel 11 y 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',
    ]);
})

Si la defines en routes/api.php no pasa por CSRF y no hace falta la excepción. La ruta no lleva auth: la firma es la autenticación. Responde 2xx en menos de 15 s; el trabajo pesado va al job. Formato y reintentos en Webhooks.

Widget en Blade

El widget <constaia-upload> captura el documento en el navegador y lo envía a tu ruta, sin clave. Pásale el token CSRF en el atributo headers: la ruta mantiene la protección CSRF normal.

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']]);

        // Del navegador solo se acepta el idioma; expect y checks los fija el servidor.
        $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' => 'No hemos podido comprobar el documento.']], 502);
        }

        return response()->json([
            'id'       => $analysis->id,
            'object'   => 'analysis',
            'status'   => $analysis->status,
            'document' => $analysis->document?->toArray(),
            'verdict'  => $analysis->verdict?->toArray(),
            'warnings' => $analysis->warnings ?? [],
        ]);
    }
}

Devuelve solo lo que el navegador necesita: el widget pinta verdict.status, verdict.reasons[].message y warnings. Los campos extraídos se quedan en tu servidor.

Errores

Todas las excepciones del SDK extienden Constaia\Exception\ConstaiaException y exponen errorCode, param, requestId y httpStatus (también con getErrorCode(), getRequestId()…).

ExcepciónHTTPQué hacer
InvalidRequestException400, 409, 413, 415, 422Mira errorCode (file_too_large, unsupported_file_type, unreadable_image, invalid_parameter…); pide otro fichero o corrige la opción
AuthenticationException401Clave ausente o revocada; revisa CONSTAIA_API_KEY y config:cache
InsufficientCreditsException402Recarga créditos en el panel
PermissionException403La clave no tiene permiso para esa acción
NotFoundException404Análisis borrado, de otra cuenta o creado con keep_results: false
RateLimitException429Ya reintentado por el SDK; retryAfter indica cuánto esperar
ApiException5xxYa reintentado; 503 live_mode_unavailable significa que el modo live no está disponible, usa ck_test_
ConnectionException—Red o timeout tras los reintentos

El SDK reintenta 429, 5xx y errores de red (max_retries, 2 por defecto) respetando Retry-After y con la misma Idempotency-Key. Guarda requestId en tus logs. Lista completa de códigos en Errores.

Tests

Con CONSTAIA_API_KEY=ck_test_... en .env.testing la API responde de forma determinista según el nombre del fichero y no cobra. UploadedFile::fake()->image() genera un JPEG real (necesita la extensión GD).

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 usa un titular que coincide con el del DNI de prueba (Juan Pérez Sánchez) para que el único error sea la caducidad, y fija el idioma es para que el mensaje llegue en español. Con dni_valid.jpg y otro nombre, el motivo holder también daría error. Ficheros y resultados en Modo test.

Checklist de producción

  • php.ini: upload_max_filesize = 20M, post_max_size = 21M, max_execution_time ≥ 90 en la ruta síncrona.
  • nginx client_max_body_size 21M; y fastcgi_read_timeout 90s;; balanceador con timeout ≥ 60 s. O mueve el análisis a un job (el timeout del job por encima de 60 s y retry_after de la conexión de cola mayor que ese timeout).
  • Rutas de subida con auth y throttle: cada análisis live consume créditos.
  • expect y checks fijos en el servidor; del widget solo aceptas language.
  • CONSTAIA_API_KEY=ck_live_... solo en producción; php artisan config:cache tras cambiarla.
  • Webhook excluido de CSRF, con firma verificada, deduplicado por webhook-id y procesado en cola.
  • Elige storage según tus obligaciones: Almacenamiento y privacidad.

Siguientes pasos

En esta página