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.
Esta guía integra Constaia en una aplicación Symfony con el SDK oficial de PHP: Constaia\Client registrado como
servicio, un servicio propio que decide qué se valida, un controlador que recibe el UploadedFile (del formulario o
del widget), Messenger para procesar en segundo plano y un webhook que verifica la firma.
Requisitos
- Symfony 6.4 o 7.x con PHP ≥ 8.2. El SDK necesita 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. symfony/messengerpara la parte asíncrona.
Instalación
composer require constaia/constaia-php
composer require symfony/messenger # opcional, para procesar en segundo planoVariables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...En producción guárdalas en el vault de Symfony (php bin/console secrets:set CONSTAIA_API_KEY --env=prod) o como
variables de entorno reales. No las pongas en .env, que se versiona.
Registrar el cliente
Constaia\Client recibe la clave y un array de configuración. Regístralo como servicio con la clave del entorno;
el autowiring lo inyectará donde lo pidas.
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
Constaia\Client:
arguments:
$apiKey: '%env(CONSTAIA_API_KEY)%'
$config:
timeout: 60
max_retries: 2Servicio de verificación
Centraliza aquí qué documento esperas y qué comprobaciones aplicas: así el controlador y el handler de Messenger usan las mismas reglas, y nada de eso llega del navegador.
<?php
namespace App\Service;
use Constaia\Analysis;
use Constaia\Client;
final class IdentityVerifier
{
public function __construct(private readonly Client $constaia)
{
}
/**
* @param \SplFileInfo|string $file UploadedFile, File o ruta local
*/
public function verify(\SplFileInfo|string $file, string $userId, string $language = 'es', ?string $filename = null, ?string $idempotencyKey = null): Analysis
{
$options = [
'expect' => ['es_dni', 'es_nie', 'passport'],
'checks' => ['not_expired' => true, 'min_age_years' => 18],
'storage' => 'none',
'language' => \in_array($language, ['es', 'en', 'pt', 'fr'], true) ? $language : 'es',
'metadata' => ['user_id' => $userId],
];
if ($filename !== null) {
$options['filename'] = $filename;
}
if ($idempotencyKey !== null) {
$options['idempotency_key'] = $idempotencyKey;
}
return $this->constaia->analyze($file, $options);
}
}Un UploadedFile de Symfony es un SplFileInfo: el SDK usa su nombre original (getClientOriginalName()) y su tipo
MIME, así que el modo test funciona sin pasar filename.
Controlador de subida
El controlador acepta el campo file, que es el que envía el widget, y responde con JSON. Exige usuario autenticado y
un token CSRF en la cabecera X-CSRF-TOKEN.
<?php
namespace App\Controller;
use App\Service\IdentityVerifier;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InsufficientCreditsException;
use Constaia\Exception\InvalidRequestException;
use Constaia\Exception\RateLimitException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[IsGranted('ROLE_USER')]
final class IdentityDocumentController extends AbstractController
{
public function __construct(
private readonly IdentityVerifier $verifier,
private readonly ValidatorInterface $validator,
private readonly LoggerInterface $logger,
) {
}
#[Route('/identity-document', name: 'identity_document_upload', methods: ['POST'])]
public function upload(Request $request): JsonResponse
{
if (!$this->isCsrfTokenValid('constaia', (string) $request->headers->get('X-CSRF-TOKEN'))) {
return $this->error('Token CSRF no válido.', 403);
}
$file = $request->files->get('file');
if (!$file instanceof UploadedFile || !$file->isValid()) {
return $this->error('No se ha recibido el documento.', 400);
}
$violations = $this->validator->validate($file, new Assert\File(
maxSize: '20M',
mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'application/pdf'],
));
if (\count($violations) > 0) {
return $this->error((string) $violations->get(0)->getMessage(), 400);
}
// Del navegador solo se acepta el idioma.
$fromBrowser = json_decode((string) $request->request->get('options'), true);
$language = \is_array($fromBrowser) ? (string) ($fromBrowser['language'] ?? 'es') : 'es';
try {
$analysis = $this->verifier->verify($file, (string) $this->getUser()?->getUserIdentifier(), $language);
} catch (InvalidRequestException $e) {
return $this->error(match ($e->errorCode) {
'file_too_large' => 'El archivo supera los 20 MB.',
'unsupported_file_type' => 'Sube una imagen (JPG, PNG, WEBP, HEIC) o un PDF.',
'unreadable_image', 'unreadable_pdf' => 'No se puede leer el documento. Prueba con otra foto.',
default => 'El documento no se ha podido procesar.',
}, 422);
} catch (InsufficientCreditsException | RateLimitException $e) {
$this->logger->error('Constaia busy', ['code' => $e->errorCode, 'request_id' => $e->requestId]);
return $this->error('El servicio está ocupado. Inténtalo en unos minutos.', 503);
} catch (ConstaiaException $e) {
$this->logger->error('Constaia error', ['status' => $e->httpStatus, 'code' => $e->errorCode, 'request_id' => $e->requestId]);
return $this->error('No hemos podido comprobar el documento. Inténtalo más tarde.', 502);
}
// Guarda aquí $analysis->id y $analysis->verdictStatus() en tu entidad.
return $this->json([
'id' => $analysis->id,
'object' => 'analysis',
'status' => $analysis->status,
'document' => $analysis->document?->toArray(),
'verdict' => $analysis->verdict?->toArray(),
'warnings' => $analysis->warnings ?? [],
]);
}
private function error(string $message, int $status): JsonResponse
{
return $this->json(['error' => ['message' => $message]], $status);
}
}La respuesta incluye solo lo que el widget necesita (verdict.status, verdict.reasons[].message, warnings); los
campos extraídos ($analysis->field('document_number'), $analysis->field('birth_date')…) se quedan en el servidor.
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.
Widget en Twig
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload
endpoint="{{ path('identity_document_upload') }}"
document="es_dni"
lang="{{ app.request.locale }}"
headers="{{ {'X-CSRF-TOKEN': csrf_token('constaia')}|json_encode|e('html_attr') }}"
></constaia-upload>Si usas un formulario clásico en lugar del widget, recibe el fichero con un FileType y pasa
$form->get('file')->getData() (un UploadedFile) a IdentityVerifier::verify().
Procesar en segundo plano con Messenger
Para no bloquear la petición, mueve el fichero a un directorio temporal y despacha un mensaje. El handler usa una
idempotency_key derivada del documento: si Messenger reintenta con el mismo fichero en 24 h, la API devuelve la
respuesta guardada sin cobrar dos veces (ver Idempotencia).
<?php
namespace App\Message;
final class AnalyzeIdentityDocument
{
public function __construct(
public readonly int $documentId,
public readonly string $path,
public readonly string $originalName,
public readonly string $userId,
) {
}
}<?php
namespace App\Controller;
use App\Message\AnalyzeIdentityDocument;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
final class AsyncUploadController extends AbstractController
{
#[Route('/identity-document/async', methods: ['POST'])]
public function __invoke(
Request $request,
MessageBusInterface $bus,
#[Autowire('%kernel.project_dir%/var/constaia-tmp')] string $tmpDir,
): JsonResponse {
$file = $request->files->get('file');
if (!$file instanceof UploadedFile || !$file->isValid() || $file->getSize() > 20 * 1024 * 1024) {
return $this->json(['error' => ['message' => 'Documento no válido.']], 400);
}
$documentId = random_int(1, PHP_INT_MAX); // usa el id de tu entidad
$originalName = $file->getClientOriginalName();
$moved = $file->move($tmpDir, bin2hex(random_bytes(16)));
$bus->dispatch(new AnalyzeIdentityDocument(
$documentId,
$moved->getPathname(),
$originalName,
(string) $this->getUser()?->getUserIdentifier(),
));
return $this->json(['id' => $documentId, 'status' => 'pending'], 202);
}
}<?php
namespace App\MessageHandler;
use App\Message\AnalyzeIdentityDocument;
use App\Service\IdentityVerifier;
use Constaia\Exception\InvalidRequestException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
#[AsMessageHandler]
final class AnalyzeIdentityDocumentHandler
{
public function __construct(
private readonly IdentityVerifier $verifier,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(AnalyzeIdentityDocument $message): void
{
try {
$analysis = $this->verifier->verify(
$message->path,
$message->userId,
filename: $message->originalName,
idempotencyKey: 'identity-document-' . $message->documentId,
);
} catch (InvalidRequestException $e) {
@unlink($message->path);
throw new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
}
$this->logger->info('Constaia analysis', [
'document_id' => $message->documentId,
'analysis_id' => $analysis->id,
'status' => $analysis->verdictStatus() ?? $analysis->status,
]);
// Actualiza aquí tu entidad con $analysis->id y el veredicto.
@unlink($message->path);
}
}InvalidRequestException (fichero ilegible, tipo no admitido…) no tiene sentido reintentarla y se marca como
irrecuperable. El resto (RateLimitException, ApiException, ConnectionException) se relanza y Messenger la
reintenta según la estrategia del transporte; el SDK ya ha hecho antes sus propios reintentos respetando Retry-After.
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 4
delay: 10000
multiplier: 3
routing:
App\Message\AnalyzeIdentityDocument: asyncArranca el worker con php bin/console messenger:consume async --time-limit=3600. Otra opción para documentos largos
es 'async' => true en las opciones: la API responde 202 al momento y el resultado llega al webhook.
Webhook
Crea el endpoint en el panel 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(), y despacha el trabajo a
Messenger para responder rápido.
<?php
namespace App\Message;
final class ConstaiaEventReceived
{
public function __construct(
public readonly string $webhookId,
public readonly array $event,
) {
}
}<?php
namespace App\Controller;
use App\Message\ConstaiaEventReceived;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final class ConstaiaWebhookController
{
#[Route('/webhooks/constaia', name: 'constaia_webhook', methods: ['POST'])]
public function __invoke(
Request $request,
MessageBusInterface $bus,
#[Autowire(env: 'CONSTAIA_WEBHOOK_SECRET')] string $secret,
): Response {
try {
$event = Webhook::verify($request->getContent(), $request->headers->all(), $secret);
} catch (SignatureVerificationException) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$bus->dispatch(new ConstaiaEventReceived((string) $request->headers->get('webhook-id'), $event));
return new Response('', Response::HTTP_NO_CONTENT);
}
}En el handler de ConstaiaEventReceived deduplica por webhookId (es el mismo en todos los reintentos) y trata
analysis.completed, analysis.review_required, analysis.failed, batch.completed y credits.low. Enruta el
mensaje a tu transporte async en messenger.yaml.
Si la aplicación tiene un firewall que exige login, abre la ruta del webhook (la firma es la autenticación):
security:
access_control:
- { path: ^/webhooks/constaia, roles: PUBLIC_ACCESS }Las rutas de controlador no llevan protección CSRF automática en Symfony, así que no hace falta excluir nada más. Formato y reintentos en Webhooks.
Errores
Excepción (Constaia\Exception\…) | HTTP | Qué hacer |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 | Mira errorCode y param; pide otro fichero o corrige la opción |
AuthenticationException | 401 | Clave ausente o revocada |
InsufficientCreditsException | 402 | Recarga créditos en el panel |
PermissionException | 403 | La clave no tiene permiso para esa acción |
NotFoundException | 404 | Análisis borrado o creado con keep_results: false |
RateLimitException | 429 | Ya reintentado por el SDK; retryAfter indica la espera |
ApiException | 5xx | Ya reintentado; 503 live_mode_unavailable indica que el modo live no está disponible, usa ck_test_ |
ConnectionException | — | Red o timeout tras los reintentos |
Todas extienden ConstaiaException con errorCode, param, requestId y httpStatus. Registra siempre
requestId. Códigos en Errores.
Tests
Con CONSTAIA_API_KEY=ck_test_... en .env.test.local la API responde según el nombre del fichero y no cobra. Copia
cualquier JPEG real a tests/fixtures/ como dni_valid.jpg y dni_expired.jpg.
<?php
namespace App\Tests\Service;
use App\Service\IdentityVerifier;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class IdentityVerifierTest extends KernelTestCase
{
private function upload(string $name): UploadedFile
{
return new UploadedFile(__DIR__ . '/../fixtures/' . $name, $name, 'image/jpeg', null, true);
}
public function testValidDni(): void
{
$analysis = static::getContainer()->get(IdentityVerifier::class)->verify($this->upload('dni_valid.jpg'), 'test');
self::assertTrue($analysis->isValid());
self::assertSame('12345678Z', $analysis->field('document_number'));
}
public function testExpiredDni(): void
{
$analysis = static::getContainer()->get(IdentityVerifier::class)->verify($this->upload('dni_expired.jpg'), 'test');
self::assertTrue($analysis->isInvalid());
self::assertSame('Caducado el 15/06/2020.', $analysis->verdict->reasons[1]->message);
}
}<?php
namespace App\Tests\Controller;
use Constaia\Webhook;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ConstaiaWebhookControllerTest extends WebTestCase
{
public function testSignedEventIsAccepted(): void
{
$client = static::createClient();
$payload = json_encode(['type' => 'analysis.completed', 'created_at' => date(DATE_ATOM), 'data' => ['id' => 'an_test']]);
$headers = Webhook::headers($payload, $_ENV['CONSTAIA_WEBHOOK_SECRET']);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value;
}
$client->request('POST', '/webhooks/constaia', server: $server, content: $payload);
self::assertResponseStatusCodeSame(204);
$client->request('POST', '/webhooks/constaia', server: ['HTTP_WEBHOOK_SIGNATURE' => 'v1,bad'] + $server, content: $payload);
self::assertResponseStatusCodeSame(400);
}
}static::getContainer() da acceso también a servicios privados como IdentityVerifier. En .env.test pon un
CONSTAIA_WEBHOOK_SECRET con formato whsec_<base64> (por ejemplo, el de un endpoint de test). Más ficheros de
prueba 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 usa Messenger. - Ruta de subida con
IsGrantedy un limitador desymfony/rate-limiterpor usuario: cada análisis live consume créditos. expectychecksfijos enIdentityVerifier; del navegador solo aceptas el fichero ylanguage.- Clave
ck_live_en el vault de secretos de producción. - Webhook con firma verificada, acceso público solo en esa ruta, deduplicación por
webhook-idy procesamiento en Messenger. - Revisa Almacenamiento y privacidad para elegir
storage.
Siguientes pasos
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.
WordPress
Valida documentos en WordPress con un plugin basado en constaia/constaia-php, con shortcode, ruta REST protegida con nonce y perfiles en el servidor.