Constaia
Integraciones

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-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.
  • symfony/messenger para la parte asíncrona.

Instalación

composer require constaia/constaia-php
composer require symfony/messenger   # opcional, para procesar en segundo plano

Variables de entorno

.env.local
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.

config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    Constaia\Client:
        arguments:
            $apiKey: '%env(CONSTAIA_API_KEY)%'
            $config:
                timeout: 60
                max_retries: 2

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

src/Service/IdentityVerifier.php
<?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.

src/Controller/IdentityDocumentController.php
<?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

templates/identity/upload.html.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).

src/Message/AnalyzeIdentityDocument.php
<?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,
    ) {
    }
}
src/Controller/AsyncUploadController.php
<?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);
    }
}
src/MessageHandler/AnalyzeIdentityDocumentHandler.php
<?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.

config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 4
                    delay: 10000
                    multiplier: 3
        routing:
            App\Message\AnalyzeIdentityDocument: async

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

src/Message/ConstaiaEventReceived.php
<?php

namespace App\Message;

final class ConstaiaEventReceived
{
    public function __construct(
        public readonly string $webhookId,
        public readonly array $event,
    ) {
    }
}
src/Controller/ConstaiaWebhookController.php
<?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):

config/packages/security.yaml
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\…)HTTPQué hacer
InvalidRequestException400, 409, 413, 415, 422Mira errorCode y param; pide otro fichero o corrige la opción
AuthenticationException401Clave ausente o revocada
InsufficientCreditsException402Recarga créditos en el panel
PermissionException403La clave no tiene permiso para esa acción
NotFoundException404Análisis borrado o creado con keep_results: false
RateLimitException429Ya reintentado por el SDK; retryAfter indica la espera
ApiException5xxYa 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.

tests/Service/IdentityVerifierTest.php
<?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);
    }
}
tests/Controller/ConstaiaWebhookControllerTest.php
<?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; y fastcgi_read_timeout 90s;; balanceador con timeout ≥ 60 s. O usa Messenger.
  • Ruta de subida con IsGranted y un limitador de symfony/rate-limiter por usuario: cada análisis live consume créditos.
  • expect y checks fijos en IdentityVerifier; del navegador solo aceptas el fichero y language.
  • 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-id y procesamiento en Messenger.
  • Revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página