Туториали

Symfony: Robust Email Validation with API Failure Fallbacks for Seamless Sign-ups

Symfony: Стабилна валидација на е-пошта со резервни опции при неуспех на API за беспрекорни регистрации

A registration form has two jobs that can pull in opposite directions: stop obviously risky addresses and let legitimate people sign up without friction. An external email-validation service improves the first job, but a brittle integration can damage the second. If a timeout, exhausted quota, or temporary upstream error blocks every registration, validation has become an outage multiplier.

This tutorial builds a Symfony registration flow that calls an email-validation API synchronously, rejects an address only after a conclusive response, and fails open when the service is temporarily unavailable. The result is practical for a small product, membership site, client portal, or newsletter-backed application: validation remains useful without becoming a single point of failure.

Get access and copy the service token

The Email Validator requires a service-scoped token. Obtain it before writing integration code:

  1. Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
  2. Open the Email Validator service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official Email Validator documentation.
  5. Find the Service token panel and copy the service-scoped token shown there.

This service does not support anonymous requests: the token is required through the token query parameter. Regenerating the token revokes the previously active token, so rotate it deliberately and update every deployed environment that uses it.

The exact request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Before building the feature, make one minimal request with placeholders:

curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
  --data-urlencode '[email protected]' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN'

The response supplies status, score, recommendation, checks, and quota. Do not paste the real token into documentation, source control, screenshots, test fixtures, or shared shell history.

Prepare the Symfony project

This implementation targets PHP 8.3 or newer and a maintained Symfony application with Doctrine, Twig, Forms, Security, Validator, HttpClient, and PHPUnit support. A conventional web application can install the relevant first-party packages with:

composer require symfony/http-client symfony/form symfony/validator \
  symfony/security-bundle symfony/twig-bundle symfony/orm-pack
composer require --dev symfony/test-pack symfony/maker-bundle

php bin/console make:user
php bin/console make:registration-form
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Use the Maker prompts to create an email-based User entity and RegistrationFormType. The generated form should contain the mapped email field and an unmapped plainPassword field. Retain its CSRF protection and place a unique database constraint on the normalized email column; application-level validation alone cannot prevent concurrent duplicate registrations.

The integration adds three small components under src/Email: an API gateway, a boundary DTO, and a registration policy. The existing registration controller consumes the policy. Messenger is unnecessary here because the user needs an immediate answer, while the deliberately short timeout keeps the request bounded.

Store configuration outside source code

Put the public endpoint in .env and the local secret in the uncommitted .env.local file:

# .env
EMAIL_VALIDATOR_ENDPOINT=https://ai.mihajlo.mk/api/email-validator/v1/check-email

# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN

Bind both values through dependency injection in config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\Email\EmailValidatorClient:
        arguments:
            $endpoint: '%env(string:EMAIL_VALIDATOR_ENDPOINT)%'
            $token: '%env(string:EMAIL_VALIDATOR_TOKEN)%'

Build a defensive API boundary

The remote response should not leak into controllers as an arbitrary array. A DTO makes the trusted shape explicit, while an outcome object distinguishes a usable assessment from an unavailable service. The mapper accepts numeric scores but refuses missing or incorrectly typed contract fields.

<?php
// src/Email/EmailValidationOutcome.php
namespace App\Email;

final readonly class EmailAssessment
{
    public function __construct(
        public string $status,
        public float $score,
        public string $recommendation,
        public array $checks,
        public array $quota,
    ) {}
}

final readonly class EmailValidationOutcome
{
    private function __construct(
        public ?EmailAssessment $assessment,
        public ?string $failure,
    ) {}

    public static function completed(EmailAssessment $assessment): self
    {
        return new self($assessment, null);
    }

    public static function unavailable(string $failure): self
    {
        return new self(null, $failure);
    }
}

The client makes at most two attempts. It retries transport failures and gateway-style 502, 503, or 504 responses once with a small backoff. It does not blindly retry authentication failures, malformed requests, or 429 responses. Retrying those immediately usually repeats the same failure and may worsen quota pressure.

<?php
// src/Email/EmailValidatorClient.php
namespace App\Email;

use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

interface EmailValidationGateway
{
    public function check(string $email): EmailValidationOutcome;
}

final class EmailValidatorClient implements EmailValidationGateway
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $endpoint,
        private string $token,
    ) {}

    public function check(string $email): EmailValidationOutcome
    {
        for ($attempt = 1; $attempt <= 2; ++$attempt) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'query' => [
                        'email' => $email,
                        'token' => $this->token,
                    ],
                    'timeout' => 1.5,
                    'max_duration' => 2.0,
                ]);
                $httpStatus = $response->getStatusCode();
            } catch (TransportExceptionInterface $exception) {
                if ($attempt < 2) {
                    usleep(200_000);
                    continue;
                }

                $this->logger->warning('Email validation transport failure', [
                    'attempts' => $attempt,
                    'exception_class' => $exception::class,
                ]);

                return EmailValidationOutcome::unavailable('transport');
            }

            if (in_array($httpStatus, [502, 503, 504], true) && $attempt < 2) {
                usleep(200_000);
                continue;
            }

            if ($httpStatus === 429) {
                $this->logger->warning('Email validation was rate limited');
                return EmailValidationOutcome::unavailable('rate_limited');
            }

            if ($httpStatus !== 200) {
                $this->logger->warning('Email validation returned an error', [
                    'http_status' => $httpStatus,
                ]);
                return EmailValidationOutcome::unavailable('http_error');
            }

            try {
                $data = json_decode(
                    $response->getContent(false),
                    true,
                    512,
                    JSON_THROW_ON_ERROR,
                );
            } catch (JsonException|TransportExceptionInterface $exception) {
                $this->logger->warning('Email validation response was unreadable', [
                    'exception_class' => $exception::class,
                ]);
                return EmailValidationOutcome::unavailable('invalid_json');
            }

            if (
                !is_array($data)
                || !is_string($data['status'] ?? null)
                || !is_numeric($data['score'] ?? null)
                || !is_string($data['recommendation'] ?? null)
                || !is_array($data['checks'] ?? null)
                || !is_array($data['quota'] ?? null)
            ) {
                $this->logger->warning('Email validation response failed schema checks');
                return EmailValidationOutcome::unavailable('invalid_schema');
            }

            return EmailValidationOutcome::completed(new EmailAssessment(
                status: $data['status'],
                score: (float) $data['score'],
                recommendation: $data['recommendation'],
                checks: $data['checks'],
                quota: $data['quota'],
            ));
        }

        return EmailValidationOutcome::unavailable('retry_exhausted');
    }
}

Notice what the logs omit: the email address, token, query string, and raw response. Those values are unnecessary for diagnosing availability and can expose credentials or personal data. Because authentication is carried in the query string by contract, configure reverse proxies and application-performance tools to redact query parameters.

Turn the response into a registration decision

The service checks syntax, domain information, MX records, provider signals, and practical delivery risk. Its recommendation should remain the authoritative semantic signal; inventing an undocumented score threshold would make the integration fragile. The policy still requires the other contract fields to be structurally usable before trusting that recommendation.

The example below uses reject as the application’s configured blocking recommendation. Confirm the exact recommendation value shown in the official documentation and responses for your activated service, then set this environment value accordingly. Unknown values fail open rather than silently becoming new blocking rules.

<?php
// src/Email/RegistrationEmailPolicy.php
namespace App\Email;

enum RegistrationEmailDecision: string
{
    case Allow = 'allow';
    case Reject = 'reject';
    case Defer = 'defer';
}

final class RegistrationEmailPolicy
{
    public function __construct(
        private string $blockingRecommendation = 'reject',
    ) {}

    public function decide(
        EmailValidationOutcome $outcome,
    ): RegistrationEmailDecision {
        $assessment = $outcome->assessment;

        if ($assessment === null) {
            return RegistrationEmailDecision::Defer;
        }

        $complete = $assessment->status !== ''
            && is_finite($assessment->score)
            && $assessment->recommendation !== ''
            && $assessment->checks !== []
            && $assessment->quota !== [];

        if (!$complete) {
            return RegistrationEmailDecision::Defer;
        }

        return hash_equals(
            strtolower($this->blockingRecommendation),
            strtolower($assessment->recommendation),
        )
            ? RegistrationEmailDecision::Reject
            : RegistrationEmailDecision::Allow;
    }
}

Bind EmailValidationGateway to the client and make the blocking value configurable:

# config/services.yaml
services:
    App\Email\EmailValidationGateway:
        alias: App\Email\EmailValidatorClient

    App\Email\RegistrationEmailPolicy:
        arguments:
            $blockingRecommendation: '%env(default:email_block_default:EMAIL_VALIDATOR_BLOCKING_RECOMMENDATION)%'

parameters:
    email_block_default: 'reject'

Protect the registration controller

Call the service only after Symfony’s local form constraints pass. That avoids spending quota on malformed input. A conclusive rejection adds a field error. An unavailable or incomplete result records an operational event but allows account creation, preserving sign-ups during temporary failures.

<?php
// Relevant body of src/Controller/RegistrationController.php
use App\Email\EmailValidationGateway;
use App\Email\RegistrationEmailDecision;
use App\Email\RegistrationEmailPolicy;
use App\Entity\User;
use App\Form\RegistrationFormType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/register', name: 'app_register')]
public function register(
    Request $request,
    UserPasswordHasherInterface $passwordHasher,
    EntityManagerInterface $entityManager,
    EmailValidationGateway $validator,
    RegistrationEmailPolicy $policy,
    LoggerInterface $logger,
): Response {
    $user = new User();
    $form = $this->createForm(RegistrationFormType::class, $user);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $outcome = $validator->check((string) $user->getEmail());
        $decision = $policy->decide($outcome);

        if ($decision === RegistrationEmailDecision::Reject) {
            $form->get('email')->addError(new FormError(
                'Please use an email address that can receive account messages.'
            ));
        } else {
            if ($decision === RegistrationEmailDecision::Defer) {
                $logger->notice('Registration continued without email assessment', [
                    'reason' => $outcome->failure ?? 'incomplete_response',
                ]);
            }

            $user->setPassword($passwordHasher->hashPassword(
                $user,
                (string) $form->get('plainPassword')->getData(),
            ));

            $entityManager->persist($user);
            $entityManager->flush();

            return $this->redirectToRoute('app_login');
        }
    }

    return $this->render('registration/register.html.twig', [
        'registrationForm' => $form,
    ]);
}

For higher assurance, require email ownership confirmation after registration. API validation estimates deliverability; it does not prove that the registrant controls the mailbox.

Test success, rejection, and failure fallback

MockHttpClient keeps tests deterministic and verifies the boundary without contacting the live service. This test covers response mapping and the essential fail-open rule:

<?php
// tests/Email/EmailValidatorClientTest.php
namespace App\Tests\Email;

use App\Email\EmailValidatorClient;
use App\Email\EmailValidationOutcome;
use App\Email\RegistrationEmailDecision;
use App\Email\RegistrationEmailPolicy;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class EmailValidatorClientTest extends TestCase
{
    public function testMapsACompleteResponse(): void
    {
        $http = new MockHttpClient(new MockResponse(json_encode([
            'status' => 'success',
            'score' => 10,
            'recommendation' => 'reject',
            'checks' => ['test_check' => false],
            'quota' => ['test_quota' => 1],
        ], JSON_THROW_ON_ERROR)));

        $client = new EmailValidatorClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
            'TEST_TOKEN',
        );

        $outcome = $client->check('[email protected]');

        self::assertSame('reject', $outcome->assessment?->recommendation);
        self::assertSame(
            RegistrationEmailDecision::Reject,
            (new RegistrationEmailPolicy('reject'))->decide($outcome),
        );
    }

    public function testUnavailableServiceDefersInsteadOfRejecting(): void
    {
        $outcome = EmailValidationOutcome::unavailable('transport');

        self::assertSame(
            RegistrationEmailDecision::Defer,
            (new RegistrationEmailPolicy('reject'))->decide($outcome),
        );
    }
}

Add a functional controller test that replaces EmailValidationGateway with a stub returning unavailable(), submits the generated registration form, and asserts that the user is persisted or redirected. Also test a conclusive rejection, invalid local form input, duplicate email handling, malformed JSON, missing response fields, 429, authentication errors, and retry exhaustion.

Security, observability, and deployment

Keep Symfony’s CSRF protection enabled, validate and normalize email locally, hash passwords through Symfony’s password hasher, and enforce the database uniqueness constraint. Apply ordinary registration rate limiting as well; otherwise an attacker can consume both application resources and validation quota.

Track structured counters for completed, rejected, deferred, rate-limited, schema-invalid, and transport-failed decisions. Alert on a sustained rise in deferred checks, but do not log addresses or tokens. Quota metadata is useful for capacity monitoring; expose only selected numeric operational values after confirming their documented shape, rather than recording the entire response.

For production, store the token in the deployment platform’s secret manager or Symfony’s secrets vault. A typical release gate is:

php bin/console secrets:set EMAIL_VALIDATOR_TOKEN --env=prod
php bin/phpunit
php bin/console lint:container --env=prod
php bin/console cache:clear --env=prod

If the token is regenerated, deploy the replacement promptly because the previous token is revoked. A rolling deployment can briefly contain instances with different tokens, so coordinate rotation with the release or accept that old instances will defer validation while registrations continue.

Common failures and final verification

  • Every request is unauthorized: confirm that the service-scoped token is active and sent as the token query parameter.
  • Requests time out under load: keep both time limits bounded and verify outbound HTTPS connectivity and DNS resolution.
  • Quota disappears unexpectedly: validate locally first, rate-limit registration, and avoid retries on 429.
  • Legitimate users are blocked: verify the documented recommendation values and keep unknown or incomplete responses in the deferred path.
  • Tokens appear in logs: redact query strings at proxies, tracing agents, and HTTP-debug middleware.

Before release, verify that a documented blocking recommendation produces a friendly field error; an allowed recommendation creates the account; timeouts, malformed responses, quota failures, and upstream errors still permit registration; no secret or email reaches logs; duplicate emails remain protected by the database; and production metrics distinguish validated registrations from deferred ones.

The durable design principle is simple: external validation may strengthen a registration decision, but availability belongs to your application. By isolating the API, validating its response defensively, retrying only plausible transient failures, and treating uncertainty as deferred rather than rejected, the sign-up path stays both protective and welcoming.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.