Туториали

Symfony: AI-Powered Email Validation for Contact Forms with Smart Caching and Fallback

Symfony: Валидација на е-пошта за контакт-форми со вештачка интелигенција, паметно кеширање и резервна опција

A contact form is easy to build and surprisingly hard to trust. Symfony can reject malformed addresses locally, but syntax alone cannot tell you whether a domain exists, publishes MX records, resembles a disposable provider, or presents practical delivery risk.

This tutorial adds those checks without making an external service a single point of failure. Successful assessments are cached, transient failures are retried within strict limits, authentication and quota errors are not retried, and an unavailable validator sends the submission into a clearly marked review path instead of losing a potential customer message.

Get access and copy the service token

Start by registering an account, or use the sign-in page if you already have one.

  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 shown there.
  5. Store it in environment-backed application configuration, never in a controller, committed configuration file, fixture, screenshot, or log.

This service requires a token. Authentication uses the token={serviceToken} query parameter. Regenerating the service token revokes the previously active token, so token rotation must update every deployed instance before old credentials are expected to work.

Confirm access with the exact endpoint

The API call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Send both token and email as query parameters. Use placeholders for the first test:

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

A usable response supplies status, score, recommendation, checks, and quota. We will validate those fields at the API boundary rather than assuming every successful HTTP response contains valid JSON in the expected shape.

Prepare the Symfony project

The example targets PHP 8.3 or newer and a current Symfony application. It uses Symfony Form, Validator, HttpClient, Cache, Mailer, Twig, and Monolog. Messenger is deliberately absent: validation must inform the immediate form decision, so moving it to a background worker would change the user-visible contract.

composer create-project symfony/skeleton contact-app
cd contact-app

composer require symfony/form symfony/validator symfony/security-csrf \
  symfony/twig-bundle symfony/http-client symfony/cache \
  symfony/mailer symfony/monolog-bundle
composer require --dev symfony/test-pack

The relevant project structure is small:

src/
  Controller/ContactController.php
  EmailValidation/EmailAssessment.php
  EmailValidation/EmailDecision.php
  EmailValidation/EmailDecisionPolicy.php
  EmailValidation/EmailValidatorClient.php
  Form/ContactType.php
templates/contact/index.html.twig
tests/EmailValidation/EmailValidatorClientTest.php
config/packages/cache.yaml
config/services.yaml
.env.local

Put local secrets in .env.local, which should remain uncommitted. Production should inject the same variables through the hosting platform or secret manager.

# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
MAILER_DSN=smtp://user:[email protected]:587
[email protected]
[email protected]

Create a dedicated cache pool and wire the client explicitly. The endpoint is configuration, while the credential comes from the environment.

# config/packages/cache.yaml
framework:
  cache:
    pools:
      cache.email_validator:
        adapter: cache.app

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

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

  App\EmailValidation\EmailValidatorClient:
    arguments:
      $cache: '@cache.email_validator'
      $serviceToken: '%env(EMAIL_VALIDATOR_TOKEN)%'
      $endpoint: 'https://ai.mihajlo.mk/api/email-validator/v1/check-email'

  App\Controller\ContactController:
    arguments:
      $contactFrom: '%env(CONTACT_FROM)%'
      $contactTo: '%env(CONTACT_TO)%'

Map the remote response into a domain object

Do not let loosely typed JSON spread through the controller. The boundary object below requires all five contractual fields, constrains the score to a local zero-to-100 policy, and represents failures separately from genuine assessments.

The thresholds are application policy, not universal truths. Here, scores below 50 are rejected, scores below 80 are reviewed, and higher scores may proceed. Revisit these thresholds using the current documentation and your tolerance for false positives. The remote recommendation remains part of the review context rather than being assigned undocumented meanings.

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

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

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

        if (
            !is_string($payload['status'] ?? null)
            || trim($payload['status']) === ''
            || !(is_int($score) || is_float($score))
            || !is_finite((float) $score)
            || $score < 0
            || $score > 100
            || !is_string($payload['recommendation'] ?? null)
            || trim($payload['recommendation']) === ''
            || !is_array($payload['checks'] ?? null)
            || !is_array($payload['quota'] ?? null)
        ) {
            throw new \UnexpectedValueException('Malformed validator response.');
        }

        return new self(
            true,
            trim($payload['status']),
            (float) $score,
            trim($payload['recommendation']),
            $payload['checks'],
            $payload['quota'],
            null,
        );
    }

    public static function unavailable(string $failure): self
    {
        return new self(false, null, null, null, [], [], $failure);
    }
}
<?php
// src/EmailValidation/EmailDecision.php
namespace App\EmailValidation;

enum EmailDecision: string
{
    case Accept = 'accept';
    case Review = 'review';
    case Reject = 'reject';
}

// src/EmailValidation/EmailDecisionPolicy.php
namespace App\EmailValidation;

final class EmailDecisionPolicy
{
    public function decide(EmailAssessment $assessment): EmailDecision
    {
        if (!$assessment->available) {
            return EmailDecision::Review;
        }

        // An incomplete operational response is never treated as high confidence.
        if (
            $assessment->status === ''
            || $assessment->recommendation === ''
            || $assessment->checks === []
            || $assessment->quota === []
        ) {
            return EmailDecision::Review;
        }

        if ($assessment->score < 50) {
            return EmailDecision::Reject;
        }

        return $assessment->score < 80
            ? EmailDecision::Review
            : EmailDecision::Accept;
    }
}

Build a cached, bounded API client

The cache key is a SHA-256 digest of the normalized address, keeping raw email addresses out of cache keys and diagnostics. Successful results live for 24 hours. Failures live for only five minutes, preventing a brief outage from causing every request to hit the provider while still allowing prompt recovery.

The client retries transport failures and server-side errors twice with short exponential backoff. It does not retry malformed requests, authentication failures, or quota responses. Those conditions require configuration, account, or input changes rather than another identical request.

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

use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class EmailValidatorClient
{
    public function __construct(
        private HttpClientInterface $http,
        private CacheInterface $cache,
        private LoggerInterface $logger,
        private string $serviceToken,
        private string $endpoint,
    ) {
        if (trim($serviceToken) === '') {
            throw new \LogicException('EMAIL_VALIDATOR_TOKEN is required.');
        }
    }

    public function check(string $email): EmailAssessment
    {
        $normalized = strtolower(trim($email));
        $key = 'email_validator.'.hash('sha256', $normalized);

        return $this->cache->get(
            $key,
            function (ItemInterface $item) use ($normalized): EmailAssessment {
                try {
                    $assessment = $this->request($normalized);
                    $item->expiresAfter(86400);

                    return $assessment;
                } catch (ValidatorUnavailable $exception) {
                    $item->expiresAfter(300);
                    $this->logger->warning('Email validator unavailable.', [
                        'failure' => $exception->reason,
                    ]);

                    return EmailAssessment::unavailable($exception->reason);
                }
            }
        );
    }

    private function request(string $email): EmailAssessment
    {
        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'query' => [
                        'token' => $this->serviceToken,
                        'email' => $email,
                    ],
                    'timeout' => 4.0,
                    'max_duration' => 8.0,
                ]);

                $code = $response->getStatusCode();

                if ($code >= 200 && $code < 300) {
                    try {
                        $payload = json_decode(
                            $response->getContent(false),
                            true,
                            512,
                            JSON_THROW_ON_ERROR
                        );

                        if (!is_array($payload)) {
                            throw new \UnexpectedValueException();
                        }

                        return EmailAssessment::fromPayload($payload);
                    } catch (\JsonException|\UnexpectedValueException) {
                        throw new ValidatorUnavailable('malformed_response');
                    }
                }

                if ($code >= 500 && $attempt < 2) {
                    $response->cancel();
                    usleep(100000 * (2 ** $attempt));
                    continue;
                }

                $response->cancel();

                throw new ValidatorUnavailable(match ($code) {
                    401, 403 => 'authentication',
                    429 => 'quota_or_rate_limit',
                    default => $code >= 500
                        ? 'remote_server'
                        : 'request_rejected',
                });
            } catch (TransportExceptionInterface) {
                if ($attempt < 2) {
                    usleep(100000 * (2 ** $attempt));
                    continue;
                }

                throw new ValidatorUnavailable('transport');
            }
        }

        throw new ValidatorUnavailable('transport');
    }
}

final class ValidatorUnavailable extends \RuntimeException
{
    public function __construct(public readonly string $reason)
    {
        parent::__construct($reason);
    }
}

Apply the decision to the contact form

Symfony’s local Email constraint remains the first line of defense. It avoids spending quota on obviously malformed input. The remote check runs only after the complete form, including CSRF protection, passes local validation.

<?php
// src/Form/ContactType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

final class ContactType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('email', EmailType::class, [
                'constraints' => [new Assert\NotBlank(), new Assert\Email()],
            ])
            ->add('message', TextareaType::class, [
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\Length(max: 5000),
                ],
            ])
            ->add('send', SubmitType::class);
    }
}

A rejected address receives a neutral error; exposing detailed provider signals would help attackers tune submissions. Review and fallback submissions are still delivered, but the subject tells the recipient that the address needs attention. The sender uses a site-owned address for DMARC compatibility, while replyTo() holds the visitor’s address.

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

use App\EmailValidation\EmailDecision;
use App\EmailValidation\EmailDecisionPolicy;
use App\EmailValidation\EmailValidatorClient;
use App\Form\ContactType;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;

final class ContactController extends AbstractController
{
    public function __construct(
        private EmailValidatorClient $validator,
        private EmailDecisionPolicy $policy,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
        private string $contactFrom,
        private string $contactTo,
    ) {}

    #[Route('/contact', name: 'contact', methods: ['GET', 'POST'])]
    public function __invoke(Request $request): Response
    {
        $form = $this->createForm(ContactType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();
            $assessment = $this->validator->check($data['email']);
            $decision = $this->policy->decide($assessment);

            $this->logger->info('Contact email validation completed.', [
                'decision' => $decision->value,
                'status' => $assessment->status,
                'score' => $assessment->score,
                'failure' => $assessment->failure,
            ]);

            if ($decision === EmailDecision::Reject) {
                $form->get('email')->addError(
                    new FormError('Please use another contact email address.')
                );
            } else {
                $label = $decision === EmailDecision::Review
                    ? '[email review]'
                    : '[contact]';

                $this->mailer->send(
                    (new Email())
                        ->from($this->contactFrom)
                        ->to($this->contactTo)
                        ->replyTo($data['email'])
                        ->subject($label.' Website contact')
                        ->text($data['message'])
                );

                $this->addFlash('success', 'Your message has been sent.');

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

        return $this->render('contact/index.html.twig', [
            'form' => $form,
        ]);
    }
}
{# templates/contact/index.html.twig #}
{% for message in app.flashes('success') %}
  <p>{{ message }}</p>
{% endfor %}

{{ form_start(form) }}
  {{ form_row(form.email) }}
  {{ form_row(form.message) }}
  {{ form_row(form.send) }}
{{ form_end(form) }}

Test the boundary without network access

MockHttpClient makes caching and failure behavior deterministic. These payloads are synthetic fixtures using only the documented top-level contract; they are not copied API responses.

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

use App\EmailValidation\EmailValidatorClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class EmailValidatorClientTest extends TestCase
{
    public function testSuccessfulResultIsMappedAndCached(): void
    {
        $calls = 0;
        $payload = json_encode([
            'status' => 'success',
            'score' => 91,
            'recommendation' => 'synthetic-recommendation',
            'checks' => ['synthetic-check' => true],
            'quota' => ['synthetic-quota' => 1],
        ], JSON_THROW_ON_ERROR);

        $http = new MockHttpClient(
            function () use (&$calls, $payload): MockResponse {
                $calls++;

                return new MockResponse($payload, ['http_code' => 200]);
            }
        );

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

        self::assertSame(91.0, $client->check('[email protected]')->score);
        self::assertSame(91.0, $client->check('[email protected]')->score);
        self::assertSame(1, $calls);
    }

    public function testAuthenticationFailureFallsBackWithoutRetry(): void
    {
        $calls = 0;
        $http = new MockHttpClient(
            function () use (&$calls): MockResponse {
                $calls++;

                return new MockResponse('', ['http_code' => 401]);
            }
        );

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

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

        self::assertFalse($assessment->available);
        self::assertSame('authentication', $assessment->failure);
        self::assertSame(1, $calls);
    }
}
php bin/phpunit
php bin/console cache:clear --env=prod
php bin/console lint:container --env=prod
php bin/console debug:router contact

Security, observability, and deployment

A query-parameter credential deserves particular care. HTTPS protects it in transit, but reverse proxies and application-performance tools may record complete URLs. Configure them to redact query strings or at least the token parameter. The client logs only a controlled failure code and decision metadata, never the token, email address, complete response, checks, or quota object.

Deploy the token and mail settings before warming the production container. If a rotated token produces authentication fallbacks, update the secret everywhere and clear the short-lived failure cache. Do not “fix” a 401 or 403 with more retries. Likewise, a 429 calls for reviewing quota and traffic, not an aggressive retry loop.

Common failures are usually recognizable:

  • Every submission enters review: inspect structured failure codes for authentication, quota, transport, or malformed responses.
  • Old assessments remain visible: clear the dedicated cache pool with php bin/console cache:pool:clear cache.email_validator.
  • Local validation passes but the remote request is rejected: confirm the exact GET endpoint and both query parameters.
  • Requests feel slow during an outage: retain bounded timeouts and keep retry counts small; do not replace them with unbounded client defaults.
  • Mail is rejected downstream: use the site-owned sender and visitor address as Reply-To, then verify the configured mail transport independently.

Final verification checklist

  • The active plan is enabled and the current service-scoped token is injected at runtime.
  • A valid local email produces one GET request and a second identical submission uses the cache.
  • Malformed local addresses consume no API request.
  • Low-score results are rejected according to the application’s documented policy.
  • Intermediate, incomplete, quota-limited, or unavailable results enter review rather than disappearing.
  • Authentication and client errors are not retried; transport and server failures receive only bounded retries.
  • Logs and proxy telemetry contain neither email addresses nor query-string tokens.
  • The production cache, mail transport, TLS access, and secret rotation procedure have all been exercised.

The important result is not merely a “smarter” form. It is a form that knows the difference between evidence, uncertainty, and failure. External validation can improve decisions, but careful caching, explicit policy, restrained retries, and a visible fallback are what make that improvement dependable in production.

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

Mihajlo

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