Native PHP Contact Forms: Cache Email Checks with AI for Faster Validation
A contact form can look healthy while quietly collecting mistyped, disposable, or unreachable addresses. Syntax validation catches obvious mistakes, but it cannot tell you whether a domain publishes mail records or whether provider signals suggest practical delivery risk.
This tutorial builds a production-oriented Native PHP 8.3 contact form that calls an email validation service, caches successful assessments, retries only transient failures, and continues operating when the remote service is unavailable. The important design choice is graceful degradation: a temporary dependency failure must not erase a legitimate customer inquiry.
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.
- Open the Email Validator service page.
- Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official Email Validator documentation.
- Find the Service token panel and copy the service-scoped token.
This service requires a token. Authentication uses the token={serviceToken} query parameter, not an Authorization header. Regenerating the token revokes the previously active token, so rotate it deliberately and update every deployed environment that uses it.
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Before writing application code, verify the credential with a minimal request:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
The response supplies status, score, recommendation, checks, and quota. We will validate those fields defensively instead of assuming undocumented score ranges or recommendation vocabulary.
Project structure and dependencies
The application keeps HTTP transport, API mapping, caching, policy, and form handling separate. That makes failure behavior testable without real network calls.
contact-form/
├── composer.json
├── .env
├── public/
│ └── index.php
├── src/
│ ├── EmailValidator.php
│ └── ContactPolicy.php
├── tests/
│ └── EmailValidatorTest.php
└── var/
└── cache/
Production HTTP calls use native cURL. PHPUnit is the only third-party package, installed for development with a PHP 8.3-compatible version:
{
"require": {
"php": "^8.3",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
composer install
mkdir -p var/cache
chmod 700 var var/cache
Store secrets in environment-backed configuration
Create .env locally, exclude it from version control, and inject equivalent variables through your deployment platform in production:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_CACHE_SECRET=replace-with-a-random-secret
EMAIL_VALIDATOR_CACHE_TTL=21600
The cache secret protects email-derived cache keys from offline guessing. Generate it with php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'. Do not log the token, the complete request URL, or raw email addresses. Because authentication is in the query string, ensure reverse proxies and observability tools do not record query strings.
Build the API boundary
The transport applies two different bounds: connection establishment gets 1.5 seconds, while the entire request gets 4 seconds. The client retries network failures, HTTP 429, and server errors twice with short exponential backoff. Authentication failures and ordinary validation responses are never blindly retried.
<?php
declare(strict_types=1);
namespace App;
interface Transport
{
public function get(string $url): HttpResponse;
}
final readonly class HttpResponse
{
public function __construct(
public int $statusCode,
public string $body,
) {}
}
final class TransportFailure extends \RuntimeException {}
final class CurlTransport implements Transport
{
public function get(string $url): HttpResponse
{
$handle = curl_init($url);
if ($handle === false) {
throw new TransportFailure('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 1500,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'contact-form/1.0',
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportFailure($message);
}
$statusCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($statusCode, $body);
}
}
final readonly class EmailAssessment
{
public function __construct(
public mixed $status,
public mixed $score,
public mixed $recommendation,
public array $checks,
public array $quota,
) {}
}
enum ValidationState: string
{
case Assessed = 'assessed';
case Degraded = 'degraded';
}
final readonly class ValidationResult
{
public function __construct(
public ValidationState $state,
public ?EmailAssessment $assessment,
public string $reason,
public bool $cached = false,
) {}
}
final class EmailValidator
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private Transport $transport,
private string $token,
private string $cacheDirectory,
private string $cacheSecret,
private int $ttlSeconds = 21600,
) {}
public function check(string $email): ValidationResult
{
$cacheFile = $this->cacheFile($email);
$cached = $this->readCache($cacheFile);
if ($cached !== null) {
return new ValidationResult(
ValidationState::Assessed,
$cached,
'fresh_cache',
true
);
}
$url = self::ENDPOINT . '?' . http_build_query(
['token' => $this->token, 'email' => $email],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->transport->get($url);
} catch (TransportFailure $exception) {
if ($attempt === 2) {
return $this->degraded('network_failure');
}
usleep(100000 * (2 ** $attempt));
continue;
}
if ($response->statusCode === 401 || $response->statusCode === 403) {
error_log('email_validator authentication_failure');
return $this->degraded('authentication_failure');
}
if ($response->statusCode === 429 ||
$response->statusCode >= 500) {
if ($attempt === 2) {
$reason = $response->statusCode === 429
? 'quota_or_rate_limit'
: 'upstream_failure';
return $this->degraded($reason);
}
usleep(100000 * (2 ** $attempt));
continue;
}
if ($response->statusCode < 200 ||
$response->statusCode >= 300) {
return $this->degraded('unexpected_http_status');
}
$assessment = $this->mapResponse($response->body);
if ($assessment === null) {
return $this->degraded('invalid_response');
}
$this->writeCache($cacheFile, $assessment);
return new ValidationResult(
ValidationState::Assessed,
$assessment,
'remote_assessment'
);
}
return $this->degraded('unreachable');
}
private function mapResponse(string $body): ?EmailAssessment
{
try {
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!is_array($data)) {
return null;
}
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $key) {
if (!array_key_exists($key, $data)) {
return null;
}
}
if (!is_array($data['checks']) || !is_array($data['quota'])) {
return null;
}
return new EmailAssessment(
$data['status'],
$data['score'],
$data['recommendation'],
$data['checks'],
$data['quota']
);
}
private function cacheFile(string $email): string
{
$identity = strtolower(trim($email));
$key = hash_hmac('sha256', $identity, $this->cacheSecret);
return rtrim($this->cacheDirectory, '/') . '/' . $key . '.json';
}
private function readCache(string $file): ?EmailAssessment
{
if (!is_file($file)) {
return null;
}
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data) ||
!isset($data['expires_at'], $data['assessment']) ||
$data['expires_at'] < time()) {
return null;
}
$json = json_encode($data['assessment']);
return is_string($json) ? $this->mapResponse($json) : null;
}
private function writeCache(
string $file,
EmailAssessment $assessment
): void {
$payload = json_encode([
'expires_at' => time() + $this->ttlSeconds,
'assessment' => [
'status' => $assessment->status,
'score' => $assessment->score,
'recommendation' => $assessment->recommendation,
'checks' => $assessment->checks,
'quota' => $assessment->quota,
],
], JSON_THROW_ON_ERROR);
$temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
if (file_put_contents($temporary, $payload, LOCK_EX) !== false) {
chmod($temporary, 0600);
rename($temporary, $file);
}
}
private function degraded(string $reason): ValidationResult
{
error_log('email_validator degraded reason=' . $reason);
return new ValidationResult(
ValidationState::Degraded,
null,
$reason
);
}
}
Only successful, structurally valid assessments enter the cache. Failures are not cached, so a brief outage does not create a long-lived false result. Atomic rename prevents readers from observing a partially written file.
Turn service data into a cautious application decision
The supplied contract names the response fields but does not, by itself, define a universal score threshold or every possible recommendation value. Inventing one would be brittle. The policy therefore makes a conservative decision: explicit negative booleans in status or nested checks send the contact to review; malformed scoring or recommendation data also triggers review. A complete assessment without explicit negative evidence is accepted.
The score, recommendation, and quota remain attached to the assessment for logs, dashboards, and later policy refinement against the official documentation. A degraded call is accepted but marked for follow-up, preserving the message without pretending validation succeeded.
<?php
declare(strict_types=1);
namespace App;
enum ContactDecision: string
{
case Accept = 'accept';
case Review = 'review';
case AcceptDegraded = 'accept_degraded';
case Reject = 'reject';
}
final class ContactPolicy
{
public function decide(
string $email,
ValidationResult $result
): ContactDecision {
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return ContactDecision::Reject;
}
if ($result->state === ValidationState::Degraded) {
return ContactDecision::AcceptDegraded;
}
$assessment = $result->assessment;
if ($assessment === null ||
$assessment->status === false ||
!is_numeric($assessment->score) ||
!is_scalar($assessment->recommendation) ||
trim((string) $assessment->recommendation) === '' ||
$this->containsFalse($assessment->checks)) {
return ContactDecision::Review;
}
return ContactDecision::Accept;
}
private function containsFalse(array $values): bool
{
foreach ($values as $value) {
if ($value === false) {
return true;
}
if (is_array($value) && $this->containsFalse($value)) {
return true;
}
}
return false;
}
}
Connect the validator to the form
The entry point performs local validation before consuming quota, verifies a CSRF token, and uses a post-redirect-get flow. The example acknowledges accepted contacts; connect the resulting decision to your existing mailer or durable contact repository. Do not send validation metadata back to the browser.
<?php
declare(strict_types=1);
use App\ContactDecision;
use App\ContactPolicy;
use App\CurlTransport;
use App\EmailValidator;
require dirname(__DIR__) . '/vendor/autoload.php';
session_start();
function envRequired(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return $value;
}
$validator = new EmailValidator(
new CurlTransport(),
envRequired('EMAIL_VALIDATOR_TOKEN'),
dirname(__DIR__) . '/var/cache',
envRequired('EMAIL_VALIDATOR_CACHE_SECRET'),
(int) (getenv('EMAIL_VALIDATOR_CACHE_TTL') ?: 21600)
);
$policy = new ContactPolicy();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = (string) ($_POST['csrf'] ?? '');
$email = trim((string) ($_POST['email'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
if (!hash_equals($_SESSION['csrf'], $csrf)) {
http_response_code(403);
$error = 'The form expired. Please try again.';
} elseif ($message === '' || mb_strlen($message) > 5000) {
$error = 'Enter a message of no more than 5,000 characters.';
} elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$error = 'Enter a valid email address.';
} else {
$result = $validator->check($email);
$decision = $policy->decide($email, $result);
error_log(sprintf(
'contact_email_check decision=%s source=%s reason=%s',
$decision->value,
$result->cached ? 'cache' : 'remote_or_fallback',
$result->reason
));
if ($decision === ContactDecision::Reject) {
$error = 'Enter a valid email address.';
} else {
$_SESSION['notice'] =
$decision === ContactDecision::Review
? 'Thanks. Your message was received for review.'
: 'Thanks. Your message was received.';
$_SESSION['csrf'] = bin2hex(random_bytes(32));
header('Location: /', true, 303);
exit;
}
}
}
$notice = $_SESSION['notice'] ?? null;
unset($_SESSION['notice']);
?>
<!doctype html>
<html lang="en">
<body>
<?php if ($notice !== null): ?>
<p><?= htmlspecialchars($notice, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php if ($error !== null): ?>
<p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') ?>">
<label>Email
<input type="email" name="email" required maxlength="254">
</label>
<label>Message
<textarea name="message" required maxlength="5000"></textarea>
</label>
<button type="submit">Send</button>
</form>
</body>
</html>
Test success, caching, and graceful fallback
A deterministic fake transport proves that the second lookup uses the cache and that an outage produces a structured degraded result. Tests never contain a real token.
<?php
declare(strict_types=1);
namespace Tests;
use App\EmailValidator;
use App\HttpResponse;
use App\Transport;
use App\TransportFailure;
use App\ValidationState;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(
private HttpResponse|\Throwable $result
) {}
public function get(string $url): HttpResponse
{
$this->calls++;
if ($this->result instanceof \Throwable) {
throw $this->result;
}
return $this->result;
}
}
final class EmailValidatorTest extends TestCase
{
public function testSuccessfulResponseIsCached(): void
{
$directory = sys_get_temp_dir() . '/email-cache-' . bin2hex(random_bytes(4));
mkdir($directory, 0700);
$transport = new FakeTransport(new HttpResponse(200, json_encode([
'status' => true,
'score' => 90,
'recommendation' => 'test-recommendation',
'checks' => ['syntax' => true],
'quota' => ['test' => true],
], JSON_THROW_ON_ERROR)));
$validator = new EmailValidator(
$transport,
'test-token',
$directory,
'test-cache-secret'
);
self::assertFalse($validator->check('[email protected]')->cached);
self::assertTrue($validator->check('[email protected]')->cached);
self::assertSame(1, $transport->calls);
}
public function testNetworkFailureDegradesAfterThreeAttempts(): void
{
$directory = sys_get_temp_dir() . '/email-cache-' . bin2hex(random_bytes(4));
mkdir($directory, 0700);
$transport = new FakeTransport(new TransportFailure('offline'));
$validator = new EmailValidator(
$transport,
'test-token',
$directory,
'test-cache-secret'
);
$result = $validator->check('[email protected]');
self::assertSame(ValidationState::Degraded, $result->state);
self::assertSame('network_failure', $result->reason);
self::assertSame(3, $transport->calls);
}
}
vendor/bin/phpunit tests
Security, observability, and deployment
- Serve the form only over HTTPS and prevent access to
.env,vendor,tests, andvarfrom the document root. - Keep
publicas the web server document root. Run PHP-FPM under a user that can write only tovar/cache. - Apply form-level IP or session throttling as well as API quota controls. Caching reduces repeated checks but does not stop submissions using many different addresses.
- Log the decision, cache source, response category, latency, and retry count. Avoid raw emails, tokens, message bodies, full URLs, and full API responses.
- Monitor degraded decisions, HTTP 429 responses, authentication failures, malformed payloads, and cache write failures separately. A rising degraded rate is an operational signal, not merely a validation result.
- During token rotation, update the environment secret and restart workers or PHP-FPM processes that retain environment values. Remember that regeneration immediately invalidates the former active token.
On multiple application servers, a local filesystem cache is correct but not shared. That may cause duplicate remote checks. Use a shared cache only when your deployment already provides one and preserve the same HMAC keying, TTL, and “successful assessments only” rule.
Common failures
Every request returns 401 or 403: confirm that the service plan is active, the service-scoped token was copied from the documentation page, and no regenerated token remains deployed.
Requests work in a terminal but fail in PHP: verify that ext-curl is enabled for the PHP-FPM runtime, not just the command-line binary. Also check outbound HTTPS and certificate trust configuration.
The service is called on every submission: check ownership of var/cache, confirm the TTL is positive, and look for cache write failures. Do not solve permissions problems with world-writable directories.
Valid contacts disappear during an outage: the form handler has probably converted a dependency failure into rejection. Reserve rejection for local syntax failure or an explicit, documented business rule; treat timeouts and quota exhaustion as degraded operation.
Final verification checklist
- The exact GET endpoint receives URL-encoded
tokenandemailparameters. - The service token and cache secret come from environment configuration.
- Local syntax validation runs before the API call.
- The boundary maps
status,score,recommendation,checks, andquota. - Connection and total timeouts are bounded.
- Only transient network, 429, and server failures are retried.
- Successful assessments are cached under HMAC-derived filenames.
- Outages preserve valid contact messages through a visible degraded state.
- Tests use a deterministic fake transport and no live credentials.
- Logs exclude tokens, raw addresses, full URLs, and message contents.
The best production validation is not the strictest gate. It is the one that improves address quality without turning an optional external check into a single point of failure. Cache what you can trust, expose uncertainty honestly, and keep the contact path open when the network is having a bad day.