Туториали

Symfony Email Validation: Keep Registrations Alive During API Downtime

Symfony валидација на е-пошта: Одржете ги регистрациите активни при прекин на API-то

A registration form has one job: let legitimate people create an account. Email risk screening can improve that flow, but only if the integration respects a crucial boundary: a temporary dependency failure is not evidence that a user supplied a bad address.

This tutorial builds a production-oriented Symfony integration that checks an address before registration, rejects only a conclusive low-scoring result, and allows registration with a pending validation state when the remote service is unavailable. Local syntax validation remains mandatory, while ownership confirmation remains the final authority.

Get access and copy the service token

Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.

  1. Open the Email Validator service page.
  2. 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 application configuration, never in PHP source code.

This service requires a token. Regenerating the token revokes the previously active token, so token rotation must update every deployed environment that uses it.

Confirm the exact request

The API call is an HTTP GET request to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token={serviceToken} query parameter, while the address is supplied through the email query parameter.

Run one minimal request with a placeholder token before writing application code:

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

The response contract supplies status, score, recommendation, checks, and quota. The service evaluates syntax, domain information, MX records, provider signals, and practical delivery risk. Because response data crosses a network boundary, the application will still validate every field before trusting it.

Store the credential locally

Use .env.local for a local Symfony installation. It is normally excluded from version control. In hosted environments, inject these values through the platform’s secret or environment-variable facility instead.

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_REJECT_BELOW=40

The threshold is an application policy, not an API guarantee or universal measure of deliverability. Calibrate it against the current documentation and representative results before enabling rejection in production.

Architecture: fail open without becoming careless

The integration has three boundaries. An API client performs transport and response mapping. A domain policy converts a valid assessment into accept, reject, or defer. The registration controller performs local validation and then applies that decision.

  • Accept: the service returned a complete successful assessment above the configured threshold.
  • Reject: the service returned a complete successful assessment below the threshold.
  • Defer: the request timed out, quota or rate limiting intervened, authentication failed, the server returned an error, or the payload was incomplete.

Deferred registration continues, but the account records a pending email-validation state. That distinction matters: fail-open behavior preserves availability, while the pending state preserves operational visibility. Normal email ownership confirmation should still be required before sensitive actions.

The relevant project structure is deliberately small:

src/
  Controller/RegistrationController.php
  EmailValidation/EmailAssessment.php
  EmailValidation/EmailGate.php
  EmailValidation/EmailValidatorClient.php
tests/
  EmailValidation/EmailValidatorClientTest.php
config/
  packages/framework.yaml
  services.yaml

Configure Symfony’s HTTP client

This implementation targets PHP 8.3 or newer and a maintained Symfony application with the first-party HttpClient and Validator components. Install missing components with:

composer require symfony/http-client symfony/validator
composer require --dev symfony/test-pack

Configure a scoped client with bounded timeouts and narrowly targeted retries. A registration request should not wait indefinitely for an optional risk signal.

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      email_validator.client:
        base_uri: 'https://ai.mihajlo.mk'
        timeout: 2.5
        max_duration: 6.0
        retry_failed:
          max_retries: 2
          delay: 200
          multiplier: 2
          max_delay: 1000
          jitter: 0.1
          http_codes: [429, 500, 502, 503, 504]

Only transient statuses are retried. Authentication failures and other client errors are not retried blindly. Two bounded retries add resilience without turning a slow dependency into a long registration outage.

Wire the scoped client and environment values explicitly:

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

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

  App\EmailValidation\EmailValidatorClient:
    arguments:
      $http: '@email_validator.client'
      $serviceToken: '%env(string:EMAIL_VALIDATOR_TOKEN)%'

  App\EmailValidation\EmailGate:
    arguments:
      $rejectBelow: '%env(int:EMAIL_VALIDATOR_REJECT_BELOW)%'

Map the remote response into a domain object

Do not pass a decoded API array through the application. A small immutable object makes malformed and unavailable states explicit.

<?php
// src/EmailValidation/EmailAssessment.php
namespace App\EmailValidation;

final readonly class EmailAssessment
{
    private function __construct(
        public bool $available,
        public ?string $status,
        public int|float|null $score,
        public ?string $recommendation,
        public ?array $checks,
        public ?array $quota,
        public ?string $failure,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $status = $payload['status'] ?? null;
        $score = $payload['score'] ?? null;
        $recommendation = $payload['recommendation'] ?? null;
        $checks = $payload['checks'] ?? null;
        $quota = $payload['quota'] ?? null;

        $valid = is_string($status)
            && $status !== ''
            && (is_int($score) || is_float($score))
            && is_string($recommendation)
            && $recommendation !== ''
            && is_array($checks)
            && is_array($quota);

        if (!$valid) {
            return self::unavailable('malformed_response');
        }

        return new self(
            true,
            $status,
            $score,
            $recommendation,
            $checks,
            $quota,
            null,
        );
    }

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

The mapper accepts only the documented top-level fields and their defensible broad types. It does not guess undocumented keys inside checks or quota. Both structures must be present before the assessment can influence rejection.

Build the resilient API client

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

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

final readonly class EmailValidatorClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $serviceToken,
    ) {}

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

            $code = $response->getStatusCode();

            if ($code === 429) {
                return $this->failure('quota_or_rate_limit', $code);
            }

            if ($code === 401 || $code === 403) {
                return $this->failure('authentication_failure', $code);
            }

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

            $payload = json_decode(
                $response->getContent(false),
                true,
                512,
                JSON_THROW_ON_ERROR
            );

            if (!is_array($payload)) {
                return $this->failure('malformed_response', $code);
            }

            $assessment = EmailAssessment::fromPayload($payload);

            if (!$assessment->available) {
                $this->logger->warning('Email validator response rejected', [
                    'reason' => $assessment->failure,
                ]);
            }

            return $assessment;
        } catch (TransportExceptionInterface $exception) {
            return $this->failure('transport_failure');
        } catch (JsonException $exception) {
            return $this->failure('invalid_json');
        }
    }

    private function failure(
        string $reason,
        ?int $httpStatus = null,
    ): EmailAssessment {
        $this->logger->warning('Email validator unavailable', [
            'reason' => $reason,
            'http_status' => $httpStatus,
        ]);

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

Notice what the log omits: the email address, token, full URL, response body, and quota values. The token must appear in the query because that is the authentication contract, but it must never be copied into application logs or error reports.

Turn the assessment into an application decision

<?php
// src/EmailValidation/EmailGate.php
namespace App\EmailValidation;

enum EmailGateDecision: string
{
    case Accept = 'accept';
    case Reject = 'reject';
    case Defer = 'defer';
}

final readonly class EmailGate
{
    public function __construct(private int $rejectBelow) {}

    public function decide(EmailAssessment $assessment): EmailGateDecision
    {
        if (
            !$assessment->available
            || strtolower($assessment->status ?? '') !== 'success'
            || $assessment->score === null
            || $assessment->recommendation === null
            || $assessment->checks === null
            || $assessment->quota === null
        ) {
            return EmailGateDecision::Defer;
        }

        return $assessment->score < $this->rejectBelow
            ? EmailGateDecision::Reject
            : EmailGateDecision::Accept;
    }
}

All five response areas participate in the decision. The recommendation, checks, and quota must be present; the status must indicate success; and only then may the score cross the application’s rejection threshold. Any ambiguity becomes defer, never reject.

Protect the registration controller

The following controller shows the integration point. AccountCreator represents the application’s existing transactional account-creation service; it should hash the password, enforce the unique-email constraint, store the validation state, and send the normal ownership-confirmation message.

<?php
// src/Controller/RegistrationController.php
namespace App\Controller;

use App\EmailValidation\EmailGate;
use App\EmailValidation\EmailGateDecision;
use App\EmailValidation\EmailValidatorClient;
use App\Registration\AccountCreator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class RegistrationController extends AbstractController
{
    #[Route('/register', name: 'register', methods: ['POST'])]
    public function __invoke(
        Request $request,
        ValidatorInterface $validator,
        EmailValidatorClient $client,
        EmailGate $gate,
        AccountCreator $accounts,
    ): JsonResponse {
        if (!$this->isCsrfTokenValid(
            'register',
            (string) $request->request->get('_token')
        )) {
            return $this->json(['error' => 'Invalid form token.'], 400);
        }

        $email = trim((string) $request->request->get('email'));
        $password = (string) $request->request->get('password');

        $violations = $validator->validate($email, [
            new Assert\NotBlank(),
            new Assert\Email(),
        ]);

        if (count($violations) > 0 || strlen($password) < 12) {
            return $this->json(['error' => 'Check the submitted fields.'], 422);
        }

        $decision = $gate->decide($client->check($email));

        if ($decision === EmailGateDecision::Reject) {
            return $this->json([
                'error' => 'Please check the email address.',
            ], 422);
        }

        $state = $decision === EmailGateDecision::Defer
            ? 'pending'
            : 'checked';

        $accounts->createPending($email, $password, $state);

        return $this->json([
            'message' => 'Check your inbox to finish registration.',
        ], 201);
    }
}

The browser receives no provider diagnosis, score, or internal recommendation. Detailed rejection explanations make account enumeration and rule probing easier. The same generic response should also be used when the email already exists.

Test success and downtime deterministically

Symfony’s MockHttpClient verifies the request without contacting the real service. These fixtures exercise the supplied contract; they are not presented as live service examples.

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

use App\EmailValidation\EmailValidatorClient;
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
    {
        $response = new MockResponse(json_encode([
            'status' => 'success',
            'score' => 91,
            'recommendation' => 'fixture-recommendation',
            'checks' => ['fixture' => true],
            'quota' => ['fixture' => 1],
        ], JSON_THROW_ON_ERROR), ['http_code' => 200]);

        $http = new MockHttpClient(function (
            string $method,
            string $url,
            array $options,
        ) use ($response): MockResponse {
            self::assertSame('GET', $method);
            self::assertStringContainsString(
                '/api/email-validator/v1/check-email',
                $url
            );
            self::assertSame('[email protected]',
                $options['query']['email']);
            self::assertSame('test-token',
                $options['query']['token']);

            return $response;
        });

        $result = (new EmailValidatorClient(
            $http,
            new NullLogger(),
            'test-token',
        ))->check('[email protected]');

        self::assertTrue($result->available);
        self::assertSame(91, $result->score);
    }

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

        $result = (new EmailValidatorClient(
            $http,
            new NullLogger(),
            'test-token',
        ))->check('[email protected]');

        self::assertFalse($result->available);
        self::assertSame('upstream_http_error', $result->failure);
    }
}

Add policy tests for scores on both sides of your threshold, plus malformed JSON, missing contract fields, HTTP 429, authentication failures, and controller behavior confirming that defer still calls createPending.

Security, observability, and deployment

Keep local syntax validation even though the external service checks syntax. It is fast, deterministic, and prevents obviously invalid input from consuming quota. Keep ownership confirmation too: risk estimation and mailbox control answer different questions.

Production dashboards should track decision counts, failure reasons, upstream status codes, and request latency. Alert on sustained authentication failures, a sharp rise in deferred decisions, or repeated quota and rate-limit responses. Use counters rather than logging personal data.

Before deployment, set the real token in the secret manager, select a conservative threshold, run the automated suite, and confirm that the Symfony production profiler is disabled. Deploy token consumers before revoking an old token during rotation; regenerating the service token immediately invalidates the previous active value.

Common failure modes

  • Every address becomes pending: verify plan activation, token injection, response mapping, and the documented successful status value.
  • HTTP 401 or 403: confirm that the service-scoped token is current and was not revoked by regeneration.
  • HTTP 429: inspect plan quota and traffic. Do not increase retries aggressively; registration should defer.
  • Unexpected rejections: disable hard rejection by lowering the policy’s aggressiveness, inspect aggregate decision data, and recalibrate the threshold.
  • Slow forms: confirm both timeout bounds and measure total retry latency from the deployed network.

Final verification checklist

  • The exact GET endpoint receives only the required email and token query parameters.
  • The token comes from environment-backed configuration and never appears in logs or fixtures.
  • Local syntax validation runs before the quota-consuming request.
  • Only complete successful responses can reject a registration.
  • Timeouts, malformed responses, quota limits, authentication failures, and server errors produce a pending state.
  • Retry behavior is bounded and limited to transient statuses.
  • Registration still succeeds when the API returns 503.
  • Email ownership confirmation remains required.
  • Metrics expose dependency health without recording addresses or tokens.

The durable lesson is broader than email validation: an optional risk service should refine a decision, not silently become the availability switch for an everyday feature. Treat confirmed evidence as evidence, treat downtime as uncertainty, and keep the registration door open when the network cannot answer.

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

Mihajlo

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