Constaia
Integrations

Symfony

Validate documents in Symfony 6.4 and 7 with constaia/constaia-php, with the client as a service, UploadedFile, Messenger for async work and a signed webhook.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

This guide integrates Constaia into a Symfony application with the official PHP SDK: Constaia\Client registered as a service, a service of your own that decides what is validated, a controller that receives the UploadedFile (from a form or the widget), Messenger for background processing and a webhook that verifies the signature.

Requirements

  • Symfony 6.4 or 7.x with PHP ≥ 8.2. The SDK needs 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.
  • symfony/messenger for the asynchronous part.

Installation

composer require constaia/constaia-php
composer require symfony/messenger   # optional, for background processing

Environment variables

.env.local
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

In production keep them in the Symfony secrets vault (php bin/console secrets:set CONSTAIA_API_KEY --env=prod) or as real environment variables. Don't put them in .env, which is committed.

Register the client

Constaia\Client takes the key and a configuration array. Register it as a service with the key from the environment; autowiring injects it wherever you ask for it.

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

Verification service

Keep here which document you expect and which checks you apply: the controller and the Messenger handler then share the same rules, and none of it comes from the browser.

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 or local path
     */
    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);
    }
}

A Symfony UploadedFile is an SplFileInfo: the SDK uses its original name (getClientOriginalName()) and MIME type, so test mode works without passing filename.

Upload controller

The controller accepts the file field, which is what the widget sends, and answers with JSON. It requires an authenticated user and a CSRF token in the X-CSRF-TOKEN header.

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('Invalid CSRF token.', 403);
        }

        $file = $request->files->get('file');
        if (!$file instanceof UploadedFile || !$file->isValid()) {
            return $this->error('No document received.', 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);
        }

        // Only the language is taken from the browser.
        $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' => 'The file is larger than 20 MB.',
                'unsupported_file_type' => 'Upload an image (JPG, PNG, WEBP, HEIC) or a PDF.',
                'unreadable_image', 'unreadable_pdf' => 'We cannot read the document. Try another photo.',
                default => 'The document could not be processed.',
            }, 422);
        } catch (InsufficientCreditsException | RateLimitException $e) {
            $this->logger->error('Constaia busy', ['code' => $e->errorCode, 'request_id' => $e->requestId]);

            return $this->error('The service is busy. Try again in a few minutes.', 503);
        } catch (ConstaiaException $e) {
            $this->logger->error('Constaia error', ['status' => $e->httpStatus, 'code' => $e->errorCode, 'request_id' => $e->requestId]);

            return $this->error('We could not check the document. Please try again later.', 502);
        }

        // Store $analysis->id and $analysis->verdictStatus() in your entity here.

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

The response contains only what the widget needs (verdict.status, verdict.reasons[].message, warnings); extracted fields ($analysis->field('document_number'), $analysis->field('birth_date')…) stay on the server. 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.

Widget in 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>

If you use a classic form instead of the widget, receive the file with a FileType and pass $form->get('file')->getData() (an UploadedFile) to IdentityVerifier::verify().

Background processing with Messenger

To avoid blocking the request, move the file to a temporary directory and dispatch a message. The handler uses an idempotency_key derived from the document: if Messenger retries with the same file within 24 h, the API returns the stored response without charging twice (see Idempotency).

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' => 'Invalid document.']], 400);
        }

        $documentId = random_int(1, PHP_INT_MAX); // use your entity id
        $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,
        ]);
        // Update your entity with $analysis->id and the verdict here.

        @unlink($message->path);
    }
}

Retrying an InvalidRequestException (unreadable file, unsupported type…) makes no sense, so it is marked as unrecoverable. The rest (RateLimitException, ApiException, ConnectionException) is rethrown and Messenger retries it following the transport's strategy; the SDK has already done its own retries honouring 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

Start the worker with php bin/console messenger:consume async --time-limit=3600. Another option for long documents is 'async' => true in the options: the API answers 202 right away and the result arrives at the webhook.

Webhook

Create the endpoint in the dashboard 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(), and dispatch the work to Messenger to answer quickly.

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

In the ConstaiaEventReceived handler, deduplicate on webhookId (the same across all retries) and handle analysis.completed, analysis.review_required, analysis.failed, batch.completed and credits.low. Route the message to your async transport in messenger.yaml.

If the application has a firewall that requires login, open the webhook route (the signature is the authentication):

config/packages/security.yaml
security:
    access_control:
        - { path: ^/webhooks/constaia, roles: PUBLIC_ACCESS }

Controller routes get no automatic CSRF protection in Symfony, so nothing else needs excluding. Format and retries in Webhooks.

Errors

Exception (Constaia\Exception\…)HTTPWhat to do
InvalidRequestException400, 409, 413, 415, 422Look at errorCode and param; ask for another file or fix the option
AuthenticationException401Missing or revoked key
InsufficientCreditsException402Top up credits in the dashboard
PermissionException403The key is not allowed to do that
NotFoundException404Analysis deleted or created with keep_results: false
RateLimitException429Already retried by the SDK; retryAfter gives the wait
ApiException5xxAlready retried; 503 live_mode_unavailable means live mode is not available, use ck_test_
ConnectionException—Network or timeout after the retries

All extend ConstaiaException with errorCode, param, requestId and httpStatus. Always log requestId. Codes in Errors.

Tests

With CONSTAIA_API_KEY=ck_test_... in .env.test.local the API answers based on the file name and charges nothing. Copy any real JPEG into tests/fixtures/ as dni_valid.jpg and dni_expired.jpg. Messages are in Spanish because language defaults to es.

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() also gives access to private services such as IdentityVerifier. In .env.test set a CONSTAIA_WEBHOOK_SECRET in whsec_<base64> format (for example, the one of a test endpoint). More test files 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 use Messenger.
  • Upload route with IsGranted and a per-user symfony/rate-limiter limiter: every live analysis consumes credits.
  • expect and checks fixed in IdentityVerifier; from the browser you accept only the file and language.
  • ck_live_ key in the production secrets vault.
  • Webhook with verified signature, public access only on that route, deduplication on webhook-id and processing in Messenger.
  • Read Storage and privacy to choose storage.

Next steps

Sur cette page