Tutorials

Symfony: Enhance Contact Forms with AI Email Validation, Caching, and Fallbacks

Symfony: Enhance Contact Forms with AI Email Validation, Caching, and Fallbacks

A contact form can pass every local validation rule and still collect an address that cannot receive a reply. Syntax validation catches typos such as a missing @, but it cannot confirm whether a domain publishes mail records, whether the provider looks plausible, or whether the address presents a practical delivery risk.

This tutorial builds those checks into a Symfony contact form without making an external service a single point of failure. Successful assessments are cached, clear failures are rejected, uncertain results are routed for review, and temporary API outages fail open so a potential customer is not silently lost.

Get access to the Email Validator service

Before writing integration code, create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login 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.

This service requires a token. Regenerating it revokes the previously active token, so treat rotation as a coordinated deployment: update the application secret everywhere before removing assumptions that the old token still works.

The exact request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address goes in email. Make one minimal request with placeholder credentials:

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

The response contract includes status, score, recommendation, checks, and quota. Do not assume undocumented enum values or a score scale. The boundary code below validates their types, while an application-owned policy interprets only explicit signals it understands.

Create the Symfony project and configuration

The implementation targets PHP 8.3 or later and a current Symfony application. Symfony’s first-party HTTP client, cache, forms, validator, CSRF protection, Mailer, and testing tools are sufficient:

composer create-project symfony/skeleton contact-validator
cd contact-validator
composer require symfony/http-client symfony/cache symfony/form symfony/validator \
  symfony/twig-bundle symfony/security-csrf symfony/mailer
composer require --dev symfony/test-pack

Keep secrets outside source control. Put local values in .env.local; inject equivalent secret environment variables through the production platform:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_CACHE_HMAC_KEY=GENERATE_A_LONG_RANDOM_VALUE
[email protected]
[email protected]
MAILER_DSN=null://null

null://null is suitable only for local development because it discards mail. Production needs the real Symfony Mailer DSN supplied by your mail provider.

Register explicit arguments in config/services.yaml. The cache HMAC key prevents normalized email addresses from appearing directly in cache keys.

parameters:
    app.email_validator.token: '%env(string:EMAIL_VALIDATOR_TOKEN)%'
    app.email_cache_hmac_key: '%env(string:EMAIL_CACHE_HMAC_KEY)%'

services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\EmailValidation\EmailValidatorClient:
        arguments:
            $serviceToken: '%app.email_validator.token%'

    App\EmailValidation\ContactEmailVerifier:
        arguments:
            $cache: '@cache.app'
            $cacheHmacKey: '%app.email_cache_hmac_key%'

Build a defensive API boundary

The client maps remote data into a small domain object. It retries only transport failures and selected server failures, with bounded backoff. Authentication errors, malformed requests, and quota responses are not blindly retried.

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

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

    public function isUsable(): bool
    {
        return $this->providerState === 'ok';
    }

    public static function unavailable(string $state, string $reason): self
    {
        return new self($state, failureReason: $reason);
    }
}
<?php
// src/EmailValidation/EmailValidatorClient.php
namespace App\EmailValidation;

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

final class EmailValidatorClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/email-validator/v1/check-email';

    public function __construct(
        private HttpClientInterface $httpClient,
        private LoggerInterface $logger,
        private string $serviceToken,
    ) {}

    public function check(string $email): EmailAssessment
    {
        $delays = [100_000, 250_000];

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->httpClient->request('GET', self::ENDPOINT, [
                    'query' => [
                        'token' => $this->serviceToken,
                        'email' => $email,
                    ],
                    'timeout' => 4.0,
                    'max_duration' => 6.0,
                ]);

                $httpStatus = $response->getStatusCode();
            } catch (TransportExceptionInterface) {
                if ($attempt < 2) {
                    usleep($delays[$attempt]);
                    continue;
                }

                $this->logger->warning('Email validation transport failure');
                return EmailAssessment::unavailable(
                    'temporary_failure',
                    'transport_failure'
                );
            }

            if ($httpStatus === 429) {
                return EmailAssessment::unavailable('quota', 'rate_limited');
            }

            if ($httpStatus === 401 || $httpStatus === 403) {
                return EmailAssessment::unavailable('auth', 'token_rejected');
            }

            if (in_array($httpStatus, [500, 502, 503, 504], true)) {
                $response->cancel();

                if ($attempt < 2) {
                    usleep($delays[$attempt]);
                    continue;
                }

                return EmailAssessment::unavailable(
                    'temporary_failure',
                    'upstream_server_error'
                );
            }

            if ($httpStatus < 200 || $httpStatus >= 300) {
                return EmailAssessment::unavailable(
                    'invalid_request',
                    'unexpected_http_status'
                );
            }

            try {
                $data = $response->toArray(false);
            } catch (DecodingExceptionInterface|TransportExceptionInterface) {
                return EmailAssessment::unavailable(
                    'malformed_response',
                    'response_not_valid_json'
                );
            }

            if (
                !is_string($data['status'] ?? null) ||
                !is_string($data['recommendation'] ?? null)
            ) {
                return EmailAssessment::unavailable(
                    'malformed_response',
                    'required_fields_missing'
                );
            }

            $score = $data['score'] ?? null;

            return new EmailAssessment(
                providerState: 'ok',
                status: $data['status'],
                score: is_numeric($score) ? (float) $score : null,
                recommendation: $data['recommendation'],
                checks: is_array($data['checks'] ?? null)
                    ? $data['checks']
                    : [],
                quota: is_array($data['quota'] ?? null)
                    ? $data['quota']
                    : [],
            );
        }

        return EmailAssessment::unavailable(
            'temporary_failure',
            'attempts_exhausted'
        );
    }
}

The token must be sent in the query string because that is the service contract. Consequently, HTTP access logs, exception tooling, and development profilers deserve special attention: redact the token parameter and never log the complete request URL.

Cache assessments and define the business decision

The verifier caches only successful API responses. Outages, authentication failures, and quota failures remain uncached so recovery is detected promptly. A six-hour lifetime reduces duplicate calls without allowing an assessment to become effectively permanent.

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

use Psr\Cache\CacheItemPoolInterface;

final class ContactEmailVerifier
{
    public function __construct(
        private EmailValidatorClient $client,
        private CacheItemPoolInterface $cache,
        private string $cacheHmacKey,
    ) {}

    public function verify(string $email): EmailAssessment
    {
        $normalized = strtolower(trim($email));
        $fingerprint = hash_hmac('sha256', $normalized, $this->cacheHmacKey);
        $item = $this->cache->getItem('email_validation.v1.'.$fingerprint);

        if ($item->isHit() && $item->get() instanceof EmailAssessment) {
            return $item->get();
        }

        $assessment = $this->client->check($normalized);

        if ($assessment->isUsable()) {
            $item->set($assessment);
            $item->expiresAfter(21_600);
            $this->cache->save($item);
        }

        return $assessment;
    }
}

The policy deliberately avoids inventing meaning for arbitrary score ranges. It rejects explicit negative status or recommendation values recognized by this application, plus explicit failures in the core syntax, domain, or MX checks. Missing score, checks, or quota information produces a review decision rather than a false claim of certainty.

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

enum EmailOutcome: string
{
    case ACCEPT = 'accept';
    case REVIEW = 'review';
    case REJECT = 'reject';
    case UNAVAILABLE = 'unavailable';
}

final readonly class ContactEmailDecision
{
    public function __construct(
        public EmailOutcome $outcome,
        public string $reason,
        public ?float $score,
        public array $checks,
        public array $quota,
    ) {}
}

final class ContactEmailPolicy
{
    public function decide(EmailAssessment $assessment): ContactEmailDecision
    {
        if (!$assessment->isUsable()) {
            return $this->decision(
                EmailOutcome::UNAVAILABLE,
                $assessment->failureReason ?? 'provider_unavailable',
                $assessment
            );
        }

        $status = strtolower(trim($assessment->status ?? ''));
        $recommendation = strtolower(trim(
            $assessment->recommendation ?? ''
        ));

        if (
            in_array($status, ['invalid', 'undeliverable'], true) ||
            in_array($recommendation, ['reject', 'block'], true) ||
            $this->coreCheckFailed($assessment->checks)
        ) {
            return $this->decision(
                EmailOutcome::REJECT,
                'explicit_negative_signal',
                $assessment
            );
        }

        if (
            $assessment->score === null ||
            $assessment->checks === [] ||
            $assessment->quota === []
        ) {
            return $this->decision(
                EmailOutcome::REVIEW,
                'incomplete_evidence',
                $assessment
            );
        }

        if (
            in_array($status, ['valid', 'deliverable'], true) ||
            in_array($recommendation, ['accept', 'allow'], true)
        ) {
            return $this->decision(
                EmailOutcome::ACCEPT,
                'affirmative_signal',
                $assessment
            );
        }

        return $this->decision(
            EmailOutcome::REVIEW,
            'unrecognized_provider_signal',
            $assessment
        );
    }

    private function coreCheckFailed(array $checks): bool
    {
        foreach (['syntax', 'domain', 'mx'] as $name) {
            $value = $checks[$name] ?? null;

            if ($value === false) {
                return true;
            }

            if (is_array($value) && ($value['valid'] ?? null) === false) {
                return true;
            }
        }

        return false;
    }

    private function decision(
        EmailOutcome $outcome,
        string $reason,
        EmailAssessment $assessment,
    ): ContactEmailDecision {
        return new ContactEmailDecision(
            $outcome,
            $reason,
            $assessment->score,
            $assessment->checks,
            $assessment->quota,
        );
    }
}

Those recognized strings are local policy, not a claim that the API guarantees every listed value. Compare them with the current official documentation before deployment and adjust the policy in one place if the documented vocabulary differs.

Connect validation to the contact form

Keep Symfony’s local validation first. It gives immediate feedback and avoids spending quota on obviously malformed input.

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

use Symfony\Component\Validator\Constraints as Assert;

final class ContactData
{
    #[Assert\NotBlank]
    #[Assert\Length(max: 120)]
    public string $name = '';

    #[Assert\NotBlank]
    #[Assert\Email(mode: 'html5')]
    #[Assert\Length(max: 254)]
    public string $email = '';

    #[Assert\NotBlank]
    #[Assert\Length(max: 5000)]
    public string $message = '';
}

// 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\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ContactType extends AbstractType
{
    public function buildForm(
        FormBuilderInterface $builder,
        array $options
    ): void {
        $builder
            ->add('name', TextType::class)
            ->add('email', EmailType::class)
            ->add('message', TextareaType::class)
            ->add('send', SubmitType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => ContactData::class]);
    }
}

The controller rejects only a firm negative result. Review and unavailable outcomes still deliver the message, but their subject prefix makes manual triage visible. This is a deliberate fail-open choice for an ordinary contact form; password recovery or financial workflows may justify a stricter policy.

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

use App\EmailValidation\ContactEmailPolicy;
use App\EmailValidation\ContactEmailVerifier;
use App\EmailValidation\EmailOutcome;
use App\Form\ContactData;
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
{
    #[Route('/contact', name: 'contact', methods: ['GET', 'POST'])]
    public function __invoke(
        Request $request,
        ContactEmailVerifier $verifier,
        ContactEmailPolicy $policy,
        MailerInterface $mailer,
        LoggerInterface $logger,
    ): Response {
        $data = new ContactData();
        $form = $this->createForm(ContactType::class, $data);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $assessment = $verifier->verify($data->email);
            $decision = $policy->decide($assessment);

            if ($decision->outcome === EmailOutcome::REJECT) {
                $form->get('email')->addError(new FormError(
                    'Please enter an email address that can receive replies.'
                ));
            } else {
                $prefix = match ($decision->outcome) {
                    EmailOutcome::ACCEPT => '',
                    EmailOutcome::REVIEW => '[email review] ',
                    EmailOutcome::UNAVAILABLE => '[validation unavailable] ',
                    EmailOutcome::REJECT => '',
                };

                $mailer->send(
                    (new Email())
                        ->from((string) $_ENV['CONTACT_FROM'])
                        ->to((string) $_ENV['CONTACT_TO'])
                        ->replyTo($data->email)
                        ->subject($prefix.'Contact request from '.$data->name)
                        ->text($data->message)
                );

                $logger->info('Contact form accepted', [
                    'email_validation_outcome' => $decision->outcome->value,
                    'email_validation_reason' => $decision->reason,
                    'score' => $decision->score,
                    'check_count' => count($decision->checks),
                    'quota_fields' => array_keys($decision->quota),
                ]);

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

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

Create templates/contact/index.html.twig. Symfony Forms supplies CSRF protection when it is enabled normally:

{% for message in app.flashes('success') %}
    <p>{{ message }}</p>
{% endfor %}

{{ form(form) }}

Test the boundary and cache deterministically

MockHttpClient exercises the real mapping logic without network access. The second test proves that repeated checks use the cache rather than spending another request.

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

use App\EmailValidation\ContactEmailVerifier;
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 EmailValidationTest extends TestCase
{
    public function testMapsACompleteResponse(): void
    {
        $response = new MockResponse(json_encode([
            'status' => 'valid',
            'score' => 92,
            'recommendation' => 'accept',
            'checks' => ['syntax' => true, 'domain' => true, 'mx' => true],
            'quota' => ['remaining' => 99],
        ]), ['http_code' => 200]);

        $client = new EmailValidatorClient(
            new MockHttpClient($response),
            new NullLogger(),
            'test-token'
        );

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

        self::assertTrue($assessment->isUsable());
        self::assertSame(92.0, $assessment->score);
        self::assertSame('accept', $assessment->recommendation);
        self::assertStringContainsString(
            'token=test-token',
            $response->getRequestUrl()
        );
    }

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

                return new MockResponse(json_encode([
                    'status' => 'valid',
                    'score' => 92,
                    'recommendation' => 'accept',
                    'checks' => ['syntax' => true],
                    'quota' => ['remaining' => 99],
                ]));
            }
        );

        $verifier = new ContactEmailVerifier(
            new EmailValidatorClient($transport, new NullLogger(), 'test-token'),
            new ArrayAdapter(),
            'test-hmac-key'
        );

        $verifier->verify('[email protected]');
        $verifier->verify('[email protected]');

        self::assertSame(1, $calls);
    }

    public function testRateLimitBecomesStructuredFallback(): void
    {
        $client = new EmailValidatorClient(
            new MockHttpClient(new MockResponse('', ['http_code' => 429])),
            new NullLogger(),
            'test-token'
        );

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

        self::assertSame('quota', $assessment->providerState);
        self::assertSame('rate_limited', $assessment->failureReason);
    }
}
php bin/phpunit
php bin/console debug:router contact
php bin/console lint:container
php bin/console lint:twig templates/

Production reliability and common failures

Use a persistent cache.app backend. Symfony’s filesystem cache is adequate on one host; multiple application instances benefit from an already-managed shared cache so each node does not repeat the same validation.

  • HTTP 401 or 403: verify the service-scoped token and confirm that a token rotation did not revoke the deployed value.
  • HTTP 429: inspect the activated plan and quota. The form continues in degraded mode rather than retrying a known rate limit.
  • Repeated upstream failures: alert on temporary_failure counts and latency, not on raw addresses or request URLs.
  • Every result goes to review: compare the documented status, recommendation, checks, score, and quota shapes with the local mapping and policy.
  • Mail appears successful but never arrives: replace the local null Mailer DSN and verify the production mail transport independently.

Apply ordinary contact-form defenses as well: CSRF protection, request throttling, message-length limits, output escaping, and abuse monitoring. Never use an email validation score as proof of identity or ownership. Only a confirmation message with a single-use token can establish control of an inbox.

Before deployment, run tests, warm the production container, and provide secrets through the hosting platform:

php bin/phpunit
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
APP_ENV=prod APP_DEBUG=0 php bin/console lint:container
composer install --no-dev --optimize-autoloader

Final verification checklist

  • The contact route renders and submits with a valid CSRF token.
  • Malformed local addresses are rejected before an API request.
  • The request uses GET, the exact endpoint, and the token and email query parameters.
  • Clear negative assessments add an error to the email field.
  • Successful assessments are cached under HMAC-derived keys.
  • Timeouts, server failures, quota limits, and invalid JSON produce structured fallback states.
  • Authentication and validation failures are not blindly retried.
  • Logs contain decisions and operational metadata, but no token, full URL, message body, or email address.
  • The production Mailer DSN, token, recipients, and cache key are injected outside source control.

The strongest integration is not the one that trusts an API most enthusiastically. It is the one that knows exactly what the API can establish, preserves every useful signal, and remains humane when the network is having a bad day. Here, better email screening reduces dead-end replies without turning a helpful contact form into a brittle gatekeeper.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.