Symfony: Boost Contact Forms with AI Email Checks, Caching, and Fallbacks
A contact form should reject obvious bad addresses without becoming dependent on a remote service. That tension shapes this implementation: Symfony performs immediate local validation, requests a richer email assessment, caches successful results, and continues accepting messages when the validator is temporarily unavailable.
The result is practical for a freelancer, product site, or small support team. Invalid-looking submissions can be stopped early, repeated checks do not consume unnecessary quota, and a network incident does not silently discard a legitimate enquiry.
Get access to the Email Validator
First, register an account or sign in. Open the Email Validator service page, choose an available Free, Plus, or Pro plan, and complete its activation.
Next, open the official Email Validator documentation. Find the Service token panel and copy the service-scoped token. This service requires that token; it is not an anonymous endpoint.
Regenerating the token revokes the previously active token. Treat rotation as a deployment change: update every environment using the old value, deploy or restart the affected processes, verify the new credential, and only then consider the rotation complete.
The exact call is an HTTPS GET request to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address is supplied through email. Make one minimal request before changing the application:
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode '[email protected]'
Inspect the documented response and your test result before defining policy values. The contract supplies status, score, recommendation, checks, and quota, but an application should not guess undocumented status names, recommendation values, or the score scale.
Put the real secret in .env.local for local development, which should remain uncommitted. Production should provide the same names through the hosting platform’s secret or environment configuration:
# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_ACCEPT_STATUS=YOUR_DOCUMENTED_ACCEPT_STATUS
EMAIL_ACCEPT_RECOMMENDATION=YOUR_DOCUMENTED_ACCEPT_RECOMMENDATION
EMAIL_MIN_SCORE=YOUR_DOCUMENTED_MINIMUM_SCORE
EMAIL_CACHE_SECRET=GENERATE_A_LONG_RANDOM_APPLICATION_SECRET
The three policy settings are deliberately explicit. Copy the exact accepted values and select a score threshold using the service documentation and the risk tolerance of this contact form. They are local business policy, not hard-coded assumptions about the API.
Architecture and project shape
The browser continues posting an ordinary form to Symfony. The controller handles required fields and basic syntax locally. A dedicated API client owns transport, retries, and response mapping. A contact-email checker applies caching and converts the assessment into an allow-or-reject decision.
Only successful assessments enter the cache. Transport errors, throttling, authentication failures, malformed JSON, and unexpected schemas produce one structured unavailable state. The contact form then fails open and marks the submission as unverified. That trade-off is appropriate when losing a genuine business enquiry costs more than accepting occasional junk. Authentication-sensitive or regulated workflows may need the opposite policy.
The relevant project files are:
src/Email/EmailAssessment.phpfor the domain representationsrc/Email/EmailValidatorClient.phpfor the API boundarysrc/Email/ContactEmailChecker.phpfor caching and policysrc/Controller/ContactController.phpfor form integrationtests/Email/for deterministic transport and cache tests
Install Symfony’s first-party HTTP client and cache components, plus the standard test pack:
composer require symfony/http-client symfony/cache
composer require --dev symfony/test-pack
Map the external response at one boundary
Do not pass arbitrary decoded JSON into the controller. Map the five contracted fields immediately, rejecting missing or incorrectly typed data as an upstream failure.
<?php
// src/Email/EmailAssessment.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,
) {}
public static function fromPayload(array $payload): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (!array_key_exists($field, $payload)) {
throw new \UnexpectedValueException("Missing response field: {$field}");
}
}
if (!is_string($payload['status'])
|| !is_int($payload['score']) && !is_float($payload['score'])
|| !is_string($payload['recommendation'])
|| !is_array($payload['checks'])
|| !is_array($payload['quota'])) {
throw new \UnexpectedValueException('Unexpected Email Validator response types.');
}
return new self(
trim($payload['status']),
(float) $payload['score'],
trim($payload['recommendation']),
$payload['checks'],
$payload['quota'],
);
}
}
final class EmailValidatorUnavailable extends \RuntimeException {}
This deliberately leaves checks and quota structurally opaque. Their nested schema was not established by the top-level contract, so the application preserves the data without inventing keys.
Build a bounded HTTP client
The client allows one initial attempt and two retries. It retries transport failures, throttling, and selected transient server responses with short exponential backoff. It does not retry validation errors or authentication failures, because repeating the same request will not repair them.
<?php
// src/Email/EmailValidatorClient.php
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
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 $http,
#[Autowire('%env(EMAIL_VALIDATOR_TOKEN)%')]
private string $token,
private LoggerInterface $logger,
) {}
public function check(string $email): EmailAssessment
{
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'query' => [
'token' => $this->token,
'email' => $email,
],
'timeout' => 2.0,
'max_duration' => 4.0,
'headers' => ['Accept' => 'application/json'],
]);
$statusCode = $response->getStatusCode();
if (in_array($statusCode, [429, 502, 503, 504], true)) {
if ($attempt === 2) {
throw new EmailValidatorUnavailable(
"Transient HTTP failure: {$statusCode}"
);
}
$this->backoff($attempt, $statusCode);
continue;
}
if ($statusCode < 200 || $statusCode >= 300) {
throw new EmailValidatorUnavailable(
"Non-retryable HTTP failure: {$statusCode}"
);
}
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new \UnexpectedValueException('JSON root is not an object.');
}
return EmailAssessment::fromPayload($payload);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 2) {
throw new EmailValidatorUnavailable(
'Email Validator transport failure.',
0,
$exception
);
}
$this->backoff($attempt, null);
} catch (\JsonException|\UnexpectedValueException $exception) {
throw new EmailValidatorUnavailable(
'Email Validator returned an unusable response.',
0,
$exception
);
}
}
throw new EmailValidatorUnavailable('Email Validator attempts exhausted.');
}
private function backoff(int $attempt, ?int $statusCode): void
{
$this->logger->warning('Email validation retry scheduled.', [
'attempt' => $attempt + 2,
'http_status' => $statusCode,
]);
usleep(100_000 * (2 ** $attempt));
}
}
The client never logs the email, token, full request URL, or response body. The timeouts bound each request, while the small retry budget prevents a struggling dependency from tying up PHP workers indefinitely.
Cache results and apply a three-state policy
A normalized email address is sensitive personal data. The cache key therefore uses an HMAC rather than embedding the address or storing a reversible value. Successful responses are cached for one hour; failures are not cached.
<?php
// src/Email/ContactEmailChecker.php
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final readonly class ContactEmailDecision
{
public function __construct(
public bool $allowed,
public bool $fallback,
public string $reason,
) {}
}
final class ContactEmailChecker
{
public function __construct(
private EmailValidatorClient $client,
private CacheInterface $cache,
private LoggerInterface $logger,
#[Autowire('%env(EMAIL_ACCEPT_STATUS)%')]
private string $acceptStatus,
#[Autowire('%env(EMAIL_ACCEPT_RECOMMENDATION)%')]
private string $acceptRecommendation,
#[Autowire('%env(float:EMAIL_MIN_SCORE)%')]
private float $minimumScore,
#[Autowire('%env(EMAIL_CACHE_SECRET)%')]
private string $cacheSecret,
) {}
public function check(string $email): ContactEmailDecision
{
$normalized = strtolower(trim($email));
$fingerprint = hash_hmac('sha256', $normalized, $this->cacheSecret);
try {
$assessment = $this->cache->get(
'contact_email.'. $fingerprint,
function (ItemInterface $item) use ($normalized): EmailAssessment {
$item->expiresAfter(3600);
return $this->client->check($normalized);
}
);
} catch (EmailValidatorUnavailable $exception) {
$this->logger->warning('Email validation unavailable; allowing fallback.', [
'email_fingerprint' => $fingerprint,
'exception' => $exception::class,
]);
return new ContactEmailDecision(true, true, 'validation_unavailable');
}
$allowed =
hash_equals($this->acceptStatus, $assessment->status)
&& hash_equals(
$this->acceptRecommendation,
$assessment->recommendation
)
&& $assessment->score >= $this->minimumScore
&& $assessment->checks !== []
&& $assessment->quota !== [];
$this->logger->info('Contact email assessment completed.', [
'email_fingerprint' => $fingerprint,
'status' => $assessment->status,
'score' => $assessment->score,
'recommendation' => $assessment->recommendation,
'checks_count' => count($assessment->checks),
'quota_present' => $assessment->quota !== [],
'allowed' => $allowed,
]);
return new ContactEmailDecision(
$allowed,
false,
$allowed ? 'accepted' : 'assessment_rejected'
);
}
}
This policy uses every contracted response area without assuming undocumented nested fields. Status and recommendation require exact configured matches, score must satisfy the configured threshold, and checks and quota must be present rather than silently disappearing after an upstream schema change.
Connect it to the contact form
Keep cheap local checks first. They improve feedback and prevent unnecessary external calls. Limit input sizes before storing, emailing, or logging anything.
<?php
// src/Controller/ContactController.php
namespace App\Controller;
use App\Email\ContactEmailChecker;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ContactController
{
#[Route('/contact', name: 'contact_submit', methods: ['POST'])]
public function __invoke(
Request $request,
ContactEmailChecker $checker,
): JsonResponse {
$name = trim((string) $request->request->get('name', ''));
$email = trim((string) $request->request->get('email', ''));
$message = trim((string) $request->request->get('message', ''));
if ($name === '' || mb_strlen($name) > 120
|| $message === '' || mb_strlen($message) > 5000
|| strlen($email) > 254
|| filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return new JsonResponse(
['ok' => false, 'error' => 'Please check the submitted fields.'],
422
);
}
$decision = $checker->check($email);
if (!$decision->allowed) {
return new JsonResponse(
['ok' => false, 'error' => 'Please use another email address.'],
422
);
}
// Persist or dispatch the already-existing contact submission here.
// Store $decision->fallback so unverified submissions can be reviewed.
return new JsonResponse([
'ok' => true,
'email_check' => $decision->fallback ? 'deferred' : 'passed',
], 201);
}
}
The email assessment remains synchronous because it determines the form response. Delivery of the accepted contact message can use the application’s existing persistence or asynchronous mail path. Adding Messenger solely for this small external check would complicate the user-facing decision without improving it.
Test success, retries, caching, and fallback
MockHttpClient makes tests deterministic and prevents accidental network access. The fixture values below define the test’s local policy; they do not claim to be service enum values.
<?php
// tests/Email/EmailValidatorClientTest.php
namespace App\Tests\Email;
use App\Email\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 testMapsContractedFields(): void
{
$response = new MockResponse(json_encode([
'status' => 'policy-ok',
'score' => 88,
'recommendation' => 'policy-accept',
'checks' => ['provider-signal' => true],
'quota' => ['available' => 10],
], JSON_THROW_ON_ERROR));
$client = new EmailValidatorClient(
new MockHttpClient($response),
'test-token',
new NullLogger()
);
$assessment = $client->check('[email protected]');
self::assertSame('policy-ok', $assessment->status);
self::assertSame(88.0, $assessment->score);
self::assertSame('policy-accept', $assessment->recommendation);
self::assertNotEmpty($assessment->checks);
self::assertNotEmpty($assessment->quota);
}
public function testRetriesTransientFailure(): void
{
$http = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse(json_encode([
'status' => 'policy-ok',
'score' => 88,
'recommendation' => 'policy-accept',
'checks' => ['present' => true],
'quota' => ['present' => true],
], JSON_THROW_ON_ERROR)),
]);
$assessment = (new EmailValidatorClient(
$http,
'test-token',
new NullLogger()
))->check('[email protected]');
self::assertSame('policy-ok', $assessment->status);
}
}
Add checker tests that call the same address twice and assert the mock transport runs once, verify that a configured policy mismatch rejects the address, and confirm that an exhausted sequence of transient responses returns allowed=true with fallback=true. Run the suite with:
php bin/phpunit
Security, observability, and deployment
Because the required authentication token appears in a query parameter, take special care with URL capture. Keep HTTPS enabled, redact the token parameter in reverse-proxy and APM configuration, and never attach full request URLs to logs or error reports. Restrict production secret access to the application runtime.
Apply CSRF protection to browser-rendered forms, retain local length limits, and rate-limit the contact route independently of the provider’s quota. Avoid returning raw status, score, checks, recommendation, or quota data to anonymous callers; those details can help attackers tune automated submissions.
Monitor retry counts, unavailable fallbacks, rejection rates, latency, and cache effectiveness. A sudden fallback increase suggests connectivity, quota, token, or upstream trouble. A sudden rejection change after deployment points toward policy configuration or a response-contract change.
During deployment, provide all five environment values, warm the Symfony cache, run the test suite, and make a controlled submission. Long-running PHP workers must be restarted after token rotation. Deploy new configuration before revoking an old credential whenever the service’s rotation workflow permits it.
Common failure modes
- Every request returns unauthorized: verify that the service plan is active and that
tokencontains the service-scoped token, not an account password or token for another service. - A rotated token still fails: restart workers and clear infrastructure-level secret caches; regeneration revoked the previous token.
- Valid-looking addresses are rejected: compare the exact documented status, recommendation, and score scale with the deployed policy values. Do not use guessed labels.
- Quota drops too quickly: confirm that the cache adapter is persistent and shared across application instances, and that normalized addresses generate identical HMAC keys.
- Requests feel slow: inspect dependency latency and retry metrics. Keep the retry budget and total duration bounded rather than raising timeouts indiscriminately.
- Fallback submissions disappear: the validator is not the final contact-delivery mechanism. Persist or dispatch accepted submissions and retain the fallback flag for review.
Final verification checklist
- The service plan is active and the token comes from the documentation page’s Service token panel.
- The token exists only in environment-backed configuration and is redacted from logs and monitoring tools.
- The application sends
GETrequests to the exact endpoint withtokenandemail. - Status, score, recommendation, checks, and quota are defensively mapped and all influence the application decision.
- Only successful assessments are cached, using a non-reversible email fingerprint.
- Authentication and validation failures are not blindly retried; transient failures receive bounded backoff.
- An unavailable validator allows the contact submission while clearly marking it unverified.
- Mocked tests cover mapping, transient retries, caching, rejection, and graceful fallback.
The strongest integration is not the one that calls an API most aggressively. It is the one that draws a clean boundary around uncertain external data, spends quota carefully, exposes useful operational signals, and still preserves the contact form’s real purpose when a dependency has a bad day: letting a person reach you.