Туториали

Symfony: Graceful Email Validation Fallbacks for Resilient User Signups

Symfony: Елегантни резервни опции за валидација на е-пошта за отпорни кориснички регистрации

A signup form has two jobs that occasionally pull in opposite directions: keep risky addresses out and let legitimate people in. Syntax checks alone cannot assess domains, MX records, provider signals, or practical delivery risk. A remote validator can—but every remote dependency eventually becomes slow, unavailable, rate-limited, or misconfigured.

The right production design is therefore not “API success or registration failure.” It is a three-way decision: accept a completed assessment, reject only a definitive policy match, or defer the assessment while allowing registration to continue. This Symfony implementation makes that distinction explicit.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
  2. Open the Email Validator service page, choose the available Free, Plus, or Pro plan, and complete its activation.
  3. Open the official Email Validator documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store it in environment-backed configuration. Regenerating the token revokes the previously active token, so deploy the replacement wherever the application runs before retiring an existing deployment.

This service requires a token. The supplied contract has no token-free mode, and the credential must be sent through the token query parameter. Never commit it, place it in a test fixture, or expose it in a screenshot.

Confirm the exact request

The integration uses GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Send both token and email as query parameters:

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

Run this once from a secure development shell and inspect the real response for your active plan. The application will consume status, score, recommendation, checks, and quota. Because the supplied contract does not define their nested keys, score range, or enum values, the code will not guess those semantics.

Bootstrap the Symfony project

The example targets PHP 8.3 or later and a current Symfony application with Doctrine, Twig, Forms, Validator, Security, and MakerBundle. Starting from an empty directory:

composer create-project symfony/skeleton signup-guard
cd signup-guard
composer require webapp symfony/http-client
composer require --dev symfony/test-pack
php bin/console make:user
php bin/console make:registration-form

Configure the database, complete the interactive Maker prompts, and retain the generated User, RegistrationFormType, and password-hashing setup. Keep a unique database index on the normalized email address; remote screening is not a substitute for local uniqueness or Symfony’s email constraint.

The relevant project structure is deliberately small:

src/
  Controller/RegistrationController.php
  Email/EmailAssessment.php
  Email/EmailDecision.php
  Email/EmailValidator.php
  Entity/User.php
  Form/RegistrationFormType.php
tests/
  Email/EmailValidatorTest.php
config/
  packages/framework.yaml
  services.yaml

Put credentials and transport limits in configuration

For local development, add the placeholder to .env.local, which should remain uncommitted. In production, have the hosting platform’s secret manager expose the same environment variable.

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_REJECT_RECOMMENDATIONS=configure_exact_value_from_documentation

The rejection list is application policy, not an invented service enum. Replace the placeholder only with exact recommendation values confirmed in the official documentation or an observed documented response. Until then, unknown recommendations are accepted and recorded rather than interpreted optimistically or rejected accidentally.

Create a scoped Symfony client and wire the service explicitly:

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      email_validator.client:
        base_uri: 'https://ai.mihajlo.mk'
        headers:
          Accept: 'application/json'
        timeout: 4
        max_duration: 5

# config/services.yaml
parameters:
  email_validator.token: '%env(string:EMAIL_VALIDATOR_TOKEN)%'
  email_validator.reject_recommendations: '%env(csv:EMAIL_VALIDATOR_REJECT_RECOMMENDATIONS)%'

services:
  App\Email\EmailValidator:
    arguments:
      $http: '@email_validator.client'
      $token: '%email_validator.token%'
      $rejectRecommendations: '%email_validator.reject_recommendations%'

The connection and total response windows are bounded. Registration cannot hang indefinitely behind DNS, TCP, TLS, or response-body delays.

Model accept, reject, and defer explicitly

A boolean cannot distinguish “the address failed policy” from “the validator was unreachable.” That distinction belongs in the domain model:

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

enum EmailDecision: string
{
    case ACCEPT = 'accept';
    case REJECT = 'reject';
    case DEFER = 'defer';
}

// src/Email/EmailAssessment.php
namespace App\Email;

final readonly class EmailAssessment
{
    public function __construct(
        public EmailDecision $decision,
        public string $reason,
        public ?string $status = null,
        public ?float $score = null,
        public ?string $recommendation = null,
        public array $checks = [],
        public array $quota = [],
    ) {}

    public static function deferred(string $reason): self
    {
        return new self(EmailDecision::DEFER, $reason);
    }
}

The deferred state is intentional business data. It lets the signup succeed while preserving the fact that screening should be revisited or monitored.

Build a defensive API boundary

The client retries one transport or server failure after 150 milliseconds. For 429, it retries only when the server supplies a numeric Retry-After of no more than one second. Authentication, validation, and other client errors are never blindly retried.

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

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

final class EmailValidator
{
    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $token,
        private readonly array $rejectRecommendations,
    ) {}

    public function check(string $email): EmailAssessment
    {
        for ($attempt = 0; $attempt < 2; ++$attempt) {
            try {
                $response = $this->http->request('GET', '/api/email-validator/v1/check-email', [
                    'query' => [
                        'token' => $this->token,
                        'email' => $email,
                    ],
                ]);

                $code = $response->getStatusCode();

                if ($code === 429) {
                    $wait = $this->retryAfter($response);

                    if ($attempt === 0 && $wait !== null && $wait <= 1) {
                        usleep($wait * 1_000_000);
                        continue;
                    }

                    return $this->defer('quota_or_rate_limit', $code);
                }

                if ($code >= 500) {
                    if ($attempt === 0) {
                        usleep(150_000);
                        continue;
                    }

                    return $this->defer('remote_server_error', $code);
                }

                if ($code !== 200) {
                    return $this->defer('remote_http_error', $code);
                }

                $data = $response->toArray(false);
            } catch (TransportExceptionInterface) {
                if ($attempt === 0) {
                    usleep(150_000);
                    continue;
                }

                return $this->defer('transport_error');
            } catch (DecodingExceptionInterface) {
                return $this->defer('invalid_json');
            }

            return $this->map($data);
        }

        return EmailAssessment::deferred('retry_exhausted');
    }

    private function map(array $data): EmailAssessment
    {
        if (
            !is_string($data['status'] ?? null)
            || (!is_int($data['score'] ?? null) && !is_float($data['score'] ?? null))
            || !is_string($data['recommendation'] ?? null)
            || !is_array($data['checks'] ?? null)
            || !is_array($data['quota'] ?? null)
        ) {
            return $this->defer('malformed_response');
        }

        $status = trim($data['status']);
        $score = (float) $data['score'];
        $recommendation = trim($data['recommendation']);

        if ($status === '' || $recommendation === '' || !is_finite($score)) {
            return $this->defer('malformed_response');
        }

        $decision = in_array(
            $recommendation,
            $this->rejectRecommendations,
            true
        ) ? EmailDecision::REJECT : EmailDecision::ACCEPT;

        $assessment = new EmailAssessment(
            $decision,
            'complete_remote_assessment',
            $status,
            $score,
            $recommendation,
            $data['checks'],
            $data['quota'],
        );

        $this->logger->info('Email screening completed.', [
            'decision' => $decision->value,
            'status' => $status,
            'score' => $score,
            'recommendation' => $recommendation,
            'check_keys' => array_keys($data['checks']),
            'quota_keys' => array_keys($data['quota']),
        ]);

        return $assessment;
    }

    private function retryAfter(ResponseInterface $response): ?int
    {
        $value = $response->getHeaders(false)['retry-after'][0] ?? null;

        return is_string($value) && ctype_digit($value)
            ? (int) $value
            : null;
    }

    private function defer(string $reason, ?int $httpStatus = null): EmailAssessment
    {
        $this->logger->warning('Email screening deferred.', [
            'reason' => $reason,
            'http_status' => $httpStatus,
        ]);

        return EmailAssessment::deferred($reason);
    }
}

All five returned fields participate in the completeness boundary and remain available to application policy. Rejection is allowed only for a structurally complete response with an explicitly configured recommendation. Missing, malformed, or unavailable data produces DEFER, never an accidental rejection.

Connect the decision to registration

Add nullable screening columns to User for state, reason, returned status, returned score, returned recommendation, and screening time. Generate and run a Doctrine migration after adding the properties. Avoid persisting the full checks or quota payload unless there is a defined operational need and retention policy.

<?php
// Relevant method in src/Entity/User.php
public function applyEmailAssessment(
    \App\Email\EmailAssessment $assessment
): void {
    $this->emailScreeningState = $assessment->decision->value;
    $this->emailScreeningReason = $assessment->reason;
    $this->emailScreeningStatus = $assessment->status;
    $this->emailScreeningScore = $assessment->score;
    $this->emailScreeningRecommendation = $assessment->recommendation;
    $this->emailScreenedAt = new \DateTimeImmutable();
}

In the generated controller, call the validator only after Symfony’s local form constraints pass. Replace the successful-submission branch with this logic:

if ($form->isSubmitted() && $form->isValid()) {
    $assessment = $emailValidator->check($user->getEmail());

    if ($assessment->decision === EmailDecision::REJECT) {
        $form->get('email')->addError(new FormError(
            'Please use a different email address.'
        ));
    } else {
        $user->applyEmailAssessment($assessment);
        $user->setPassword($passwordHasher->hashPassword(
            $user,
            $form->get('plainPassword')->getData()
        ));

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

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

Inject EmailValidator, UserPasswordHasherInterface, and EntityManagerInterface into the controller action or constructor, and import EmailDecision and FormError. Keep the form’s CSRF protection enabled. A deferred user proceeds exactly like an accepted user, but carries a visible operational state for later reconciliation.

Messenger is unnecessary on the critical path: the application needs the best-effort result before deciding whether to display an email error. A later command or queued recheck can process deferred records, but signup correctness must not depend on a worker being alive.

Test decisions without making network calls

Symfony’s MockHttpClient gives deterministic contract and failure tests:

<?php
namespace App\Tests\Email;

use App\Email\EmailDecision;
use App\Email\EmailValidator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class EmailValidatorTest extends TestCase
{
    public function testConfiguredRecommendationRejectsCompleteResponse(): void
    {
        $response = new MockResponse(json_encode([
            'status' => 'complete',
            'score' => 0.42,
            'recommendation' => 'policy-deny',
            'checks' => ['syntax' => true],
            'quota' => ['remaining' => 5],
        ], JSON_THROW_ON_ERROR));

        $validator = new EmailValidator(
            new MockHttpClient($response),
            new NullLogger(),
            'TEST_TOKEN',
            ['policy-deny'],
        );

        self::assertSame(
            EmailDecision::REJECT,
            $validator->check('[email protected]')->decision
        );
    }

    public function testRepeatedServerFailureDefersSignupDecision(): void
    {
        $client = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse('', ['http_code' => 503]),
        ]);

        $validator = new EmailValidator(
            $client,
            new NullLogger(),
            'TEST_TOKEN',
            ['policy-deny'],
        );

        self::assertSame(
            EmailDecision::DEFER,
            $validator->check('[email protected]')->decision
        );
    }
}

The fixture values are deliberately application-owned test values; they do not claim undocumented service enums or score meaning. Add controller tests for CSRF, duplicate email, rejection, and deferred persistence. Also test missing fields and invalid JSON so a response-contract change fails safely.

Security, observability, and deployment

Query-string authentication deserves special care. Do not log request URLs, exception messages containing URLs, or Symfony HTTP traces in production, because they may contain the token. Keep APP_DEBUG=0, restrict profiler access, and log only the sanitized decision metadata shown above. Avoid logging the submitted email; if correlation is essential, use an application-specific non-reversible identifier.

Create counters for accepted, rejected, deferred, malformed, rate-limited, authentication-error, and server-error outcomes. Alert on changes in proportions rather than individual failures. Monitor documented quota fields through a small adapter once their exact keys are confirmed; do not bake guessed nested fields into core registration logic.

During deployment, supply the token through the platform’s secret facility, run migrations before serving code that writes the new columns, and warm the production cache:

composer install --no-dev --classmap-authoritative
php bin/console doctrine:migrations:migrate --no-interaction
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
php bin/phpunit

Common failure modes

  • Every request returns 401 or 403: verify activation, token placement, and whether the token was regenerated. Do not retry these responses.
  • Valid signups feel slow: confirm the bounded timeouts and examine network latency. Do not increase them casually on a synchronous form path.
  • Unexpected recommendations never reject: compare the configured list with exact documented values, including case. Unknown values intentionally fail open.
  • 429 responses spike: inspect plan quota and traffic patterns. Long sleeps inside PHP workers are worse than deferring the assessment.
  • Duplicate users appear: restore the database unique index. Application validation alone cannot prevent concurrent insert races.
  • Tokens appear in logs: rotate the token immediately, then remove URL-level HTTP logging and sanitize historical access where policy permits.

Final verification checklist

  • A locally invalid email is stopped before any remote request.
  • A complete response maps all five contract fields and applies the configured recommendation policy.
  • A repeated 503, transport failure, malformed body, or unusable 429 becomes DEFER.
  • Deferred registration creates the user and records the screening state.
  • Authentication and validation failures are not retried.
  • The token exists only in environment-backed configuration and never appears in application logs.
  • The database enforces email uniqueness, migrations are applied, and the automated suite passes.

A resilient signup form is not one that assumes its dependencies never fail. It is one that knows the difference between evidence and absence of evidence. By making “defer” a first-class outcome, Symfony can use richer email intelligence when it is available without turning a temporary API problem into a locked front door.

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

Mihajlo

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