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-curlyext-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-phpLaravel 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-configEso crea config/constaia.php con api_key, base_url, timeout, max_retries y webhook_secret, todos leídos
del entorno.
Variables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
# CONSTAIA_TIMEOUT=60
# CONSTAIA_MAX_RETRIES=2Si 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.
<?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.');
}
}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.
<?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:
<?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).
<?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);
}
}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().
<?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();
}
}<?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):
use App\Http\Controllers\ConstaiaWebhookController;
Route::post('/webhooks/constaia', ConstaiaWebhookController::class);->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.
<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']]);
// 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ón | HTTP | Qué hacer |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 | Mira errorCode (file_too_large, unsupported_file_type, unreadable_image, invalid_parameter…); pide otro fichero o corrige la opción |
AuthenticationException | 401 | Clave ausente o revocada; revisa CONSTAIA_API_KEY y config:cache |
InsufficientCreditsException | 402 | Recarga créditos en el panel |
PermissionException | 403 | La clave no tiene permiso para esa acción |
NotFoundException | 404 | Análisis borrado, de otra cuenta o creado con keep_results: false |
RateLimitException | 429 | Ya reintentado por el SDK; retryAfter indica cuánto esperar |
ApiException | 5xx | Ya 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).
<?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;yfastcgi_read_timeout 90s;; balanceador con timeout ≥ 60 s. O mueve el análisis a un job (eltimeoutdel job por encima de 60 s yretry_afterde la conexión de cola mayor que esetimeout). - Rutas de subida con
authythrottle: cada análisis live consume créditos. expectychecksfijos en el servidor; del widget solo aceptaslanguage.CONSTAIA_API_KEY=ck_live_...solo en producción;php artisan config:cachetras cambiarla.- Webhook excluido de CSRF, con firma verificada, deduplicado por
webhook-idy procesado en cola. - Elige
storagesegún tus obligaciones: Almacenamiento y privacidad.
Siguientes pasos
PHP
Valida documentos desde PHP 8.1+ sin framework con el SDK constaia/constaia-php: formulario con $_FILES, webhook firmado y variante con cURL y CURLFile.
Symfony
Valida documentos en Symfony 6.4 y 7 con constaia/constaia-php, con el cliente como servicio, UploadedFile, Messenger para lo asíncrono y un webhook firmado.