Туториали

Secure Contact Forms: Native PHP 8.3 Email Validation with Smart Caching

Безбедни контактни форми: природна PHP 8.3 валидација на е-пошта со паметно кеширање

A contact form can be perfectly valid HTML and still collect addresses that cannot receive a reply. Browser validation catches obvious typos, but it does not examine domains, MX records, provider signals, or practical delivery risk. The result is familiar: unanswered enquiries, wasted follow-up, and noisy form submissions.

This tutorial builds a compact Native PHP 8.3 application that validates an address locally, consults the Email Validator service, caches successful assessments, and continues accepting legitimate messages when the remote service is temporarily unavailable. Accepted submissions are stored in SQLite, while uncertain submissions are marked for later review rather than silently discarded.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Email Validator service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token.

This service requires authentication. The token is sent through the token={serviceToken} query parameter. Regenerating the token revokes the previously active token, so coordinate rotation with deployment and update every running instance promptly.

The exact request is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. It requires an email query parameter in addition to the token. Make one minimal request before building the feature:

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

Compare the returned JSON with the official documentation. In particular, record the documented values and scale used by status, score, and recommendation. The supplied contract names these fields but does not justify guessing their enum values or score range.

Store the credential and your explicit application policy in environment-backed configuration. Do not commit this file:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_ALLOWED_STATUSES=REPLACE_WITH_DOCUMENTED_ACCEPTED_STATUS
EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS=REPLACE_WITH_DOCUMENTED_ACCEPTED_RECOMMENDATION
EMAIL_VALIDATOR_MIN_SCORE=REPLACE_WITH_A_THRESHOLD_IN_THE_DOCUMENTED_SCALE
EMAIL_VALIDATOR_CACHE_KEY=REPLACE_WITH_A_LONG_RANDOM_SECRET
EMAIL_VALIDATOR_CACHE_TTL=21600
APP_DB_PATH=/srv/contact-form/var/app.sqlite

Keeping the policy values configurable matters. It lets the application use the service response without embedding undocumented assumptions about what a particular word or number means.

Architecture and failure policy

The request path remains deliberately synchronous because the visitor needs an immediate decision. PHP performs cheap local validation first, then checks the cache, and only then calls the service. A successful response is mapped into a typed domain object before any policy code sees it.

  • A policy-approved assessment stores the message as verified.
  • A policy rejection returns a neutral validation error and stores nothing.
  • A transport error, malformed response, exhausted operational state, or other uncertain result stores the message as deferred.
  • Only structurally valid service responses are cached.

The deferred path is intentional. Rejecting every enquiry during a network incident makes the contact form depend entirely on another system’s availability. Accepting uncertain messages preserves the user’s work, while the stored state lets an operator recheck them later. Do not send automated replies to deferred addresses until they have been reviewed.

The project uses this structure:

contact-form/
├── composer.json
├── database/schema.sql
├── public/index.php
├── src/
│   ├── CurlTransport.php
│   ├── EmailAssessment.php
│   ├── EmailPolicy.php
│   └── EmailValidator.php
├── tests/EmailValidatorTest.php
└── var/

Bootstrap PHP, SQLite, and PHPUnit

PHP 8.3, cURL, PDO SQLite, JSON, Composer, and the SQLite command-line client are the prerequisites. PHPUnit 11 supports PHP 8.3 and is the only development dependency:

{
  "require": {
    "php": ">=8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}
CREATE TABLE email_cache (
    cache_key TEXT PRIMARY KEY,
    payload TEXT NOT NULL,
    expires_at INTEGER NOT NULL
);

CREATE TABLE contacts (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    message TEXT NOT NULL,
    validation_state TEXT NOT NULL,
    api_metadata TEXT,
    created_at TEXT NOT NULL
);
composer install
mkdir -p var
chmod 700 var
sqlite3 var/app.sqlite < database/schema.sql
composer dump-autoload

The database and its parent directory must be writable by the PHP-FPM user but must not live beneath public/.

Map the API response at the boundary

The application uses all five contracted response areas: status, score, recommendation, checks, and quota. Unknown extra fields are ignored. Missing or incorrectly typed required fields turn the response into a controlled failure instead of leaking loosely typed data into business logic.

<?php
// src/EmailAssessment.php
declare(strict_types=1);

namespace App;

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 fromArray(array $data): self
    {
        foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
            if (!array_key_exists($field, $data)) {
                throw new \UnexpectedValueException("Missing API field: {$field}");
            }
        }

        if (!is_string($data['status']) ||
            !is_string($data['recommendation']) ||
            !is_numeric($data['score']) ||
            !is_array($data['checks']) ||
            !is_array($data['quota'])) {
            throw new \UnexpectedValueException('Invalid Email Validator response types');
        }

        $score = (float) $data['score'];
        if (!is_finite($score)) {
            throw new \UnexpectedValueException('Non-finite score');
        }

        return new self(
            trim($data['status']),
            $score,
            trim($data['recommendation']),
            $data['checks'],
            $data['quota'],
        );
    }

    public function toArray(): array
    {
        return [
            'status' => $this->status,
            'score' => $this->score,
            'recommendation' => $this->recommendation,
            'checks' => $this->checks,
            'quota' => $this->quota,
        ];
    }
}

Build a bounded, retry-aware cURL transport

The transport retries only network failures, HTTP 429, and server-side failures. Authentication errors and other 4xx responses are not retried because repetition will not repair an invalid token or request. The backoff is short and bounded so one form submission cannot occupy a PHP worker indefinitely.

<?php
// src/CurlTransport.php
declare(strict_types=1);

namespace App;

final class CurlTransport
{
    public function get(string $endpoint, array $query): array
    {
        $url = $endpoint . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
        $lastError = 'request failed';

        for ($attempt = 0; $attempt < 3; $attempt++) {
            $headers = [];
            $handle = curl_init($url);

            curl_setopt_array($handle, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT_MS => 1000,
                CURLOPT_TIMEOUT_MS => 3000,
                CURLOPT_HTTPHEADER => ['Accept: application/json'],
                CURLOPT_USERAGENT => 'contact-form/1.0',
                CURLOPT_HEADERFUNCTION => static function (
                    \CurlHandle $curl,
                    string $line
                ) use (&$headers): int {
                    $parts = explode(':', $line, 2);
                    if (count($parts) === 2) {
                        $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                    }
                    return strlen($line);
                },
            ]);

            $body = curl_exec($handle);
            $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
            $curlError = curl_error($handle);
            curl_close($handle);

            if ($body !== false && $status >= 200 && $status < 300) {
                $decoded = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
                if (!is_array($decoded)) {
                    throw new \UnexpectedValueException('Expected a JSON object');
                }
                return $decoded;
            }

            $retryable = $body === false || $status === 429 || $status >= 500;
            $lastError = $body === false ? $curlError : "HTTP {$status}";

            if (!$retryable || $attempt === 2) {
                throw new \RuntimeException("Email validation failed: {$lastError}");
            }

            $retryAfter = isset($headers['retry-after']) &&
                ctype_digit($headers['retry-after'])
                ? min(2, (int) $headers['retry-after'])
                : 0;

            $delayMicroseconds = $retryAfter > 0
                ? $retryAfter * 1_000_000
                : (100_000 * (2 ** $attempt)) + random_int(0, 50_000);

            usleep($delayMicroseconds);
        }

        throw new \RuntimeException("Email validation failed: {$lastError}");
    }
}

A 429 response may indicate a short rate limit or exhausted allowance. The transport respects a small numeric Retry-After, but the three-attempt ceiling prevents an excessive server value from pinning the request. Persistent quota problems reach the deferred path and should trigger an operational alert.

Add privacy-conscious caching and domain policy

Email addresses are personal data. Cache keys therefore use keyed HMAC rather than storing an address or an easily reversible plain hash in the cache table. Successful assessments receive a finite TTL; transport errors are never cached.

<?php
// src/EmailValidator.php
declare(strict_types=1);

namespace App;

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

    public function __construct(
        private \PDO $db,
        private CurlTransport $transport,
        private string $token,
        private string $cacheSecret,
        private int $ttl,
    ) {}

    public function check(string $email): EmailAssessment
    {
        $normalized = strtolower(trim($email));
        $key = hash_hmac('sha256', $normalized, $this->cacheSecret);

        $query = $this->db->prepare(
            'SELECT payload FROM email_cache
             WHERE cache_key = :key AND expires_at > :now'
        );
        $query->execute(['key' => $key, 'now' => time()]);
        $cached = $query->fetchColumn();

        if (is_string($cached)) {
            try {
                return EmailAssessment::fromArray(
                    json_decode($cached, true, 64, JSON_THROW_ON_ERROR)
                );
            } catch (\Throwable) {
                $delete = $this->db->prepare(
                    'DELETE FROM email_cache WHERE cache_key = :key'
                );
                $delete->execute(['key' => $key]);
            }
        }

        $payload = $this->transport->get(self::ENDPOINT, [
            'email' => $normalized,
            'token' => $this->token,
        ]);
        $assessment = EmailAssessment::fromArray($payload);

        $write = $this->db->prepare(
            'INSERT INTO email_cache(cache_key, payload, expires_at)
             VALUES(:key, :payload, :expires)
             ON CONFLICT(cache_key) DO UPDATE SET
               payload = excluded.payload,
               expires_at = excluded.expires_at'
        );
        $write->execute([
            'key' => $key,
            'payload' => json_encode(
                $assessment->toArray(),
                JSON_THROW_ON_ERROR
            ),
            'expires' => time() + $this->ttl,
        ]);

        return $assessment;
    }
}
<?php
// src/EmailPolicy.php
declare(strict_types=1);

namespace App;

final readonly class EmailPolicy
{
    public function __construct(
        private array $allowedStatuses,
        private array $allowedRecommendations,
        private float $minimumScore,
    ) {}

    public function decide(EmailAssessment $result): string
    {
        if ($result->quota === []) {
            return 'deferred';
        }

        if (!in_array($result->status, $this->allowedStatuses, true) ||
            !in_array(
                $result->recommendation,
                $this->allowedRecommendations,
                true
            ) ||
            $result->score < $this->minimumScore ||
            $this->containsExplicitFailure($result->checks)) {
            return 'rejected';
        }

        return 'verified';
    }

    private function containsExplicitFailure(array $checks): bool
    {
        foreach ($checks as $value) {
            if ($value === false) {
                return true;
            }
            if (is_array($value) && $this->containsExplicitFailure($value)) {
                return true;
            }
        }
        return false;
    }
}

This policy is intentionally strict: any explicit Boolean failure in checks rejects the address. If the documented response includes informational Boolean fields that may legitimately be false, replace the recursive rule with a named allowlist of the documented check keys.

Connect the validator to the contact form

The front controller enforces CSRF protection, input length limits, local syntax validation, and prepared database statements. It never shows provider details to visitors and never logs the token.

<?php
// public/index.php
declare(strict_types=1);

use App\CurlTransport;
use App\EmailPolicy;
use App\EmailValidator;

require dirname(__DIR__) . '/vendor/autoload.php';
session_start();

$required = static function (string $name): string {
    $value = getenv($name);
    if ($value === false || trim($value) === '') {
        throw new RuntimeException("Missing environment variable: {$name}");
    }
    return trim($value);
};

$db = new PDO('sqlite:' . $required('APP_DB_PATH'), null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$validator = new EmailValidator(
    $db,
    new CurlTransport(),
    $required('EMAIL_VALIDATOR_TOKEN'),
    $required('EMAIL_VALIDATOR_CACHE_KEY'),
    (int) $required('EMAIL_VALIDATOR_CACHE_TTL'),
);

$policy = new EmailPolicy(
    array_map('trim', explode(',', $required('EMAIL_VALIDATOR_ALLOWED_STATUSES'))),
    array_map(
        'trim',
        explode(',', $required('EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS'))
    ),
    (float) $required('EMAIL_VALIDATOR_MIN_SCORE'),
);

$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$message = '';
$httpStatus = 200;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = (string) ($_POST['csrf'] ?? '');
    $name = trim((string) ($_POST['name'] ?? ''));
    $email = strtolower(trim((string) ($_POST['email'] ?? '')));
    $body = trim((string) ($_POST['message'] ?? ''));

    if (!hash_equals($_SESSION['csrf'], $token)) {
        $httpStatus = 403;
        $message = 'Please reload the form and try again.';
    } elseif (
        $name === '' || strlen($name) > 120 ||
        strlen($email) > 254 ||
        !filter_var($email, FILTER_VALIDATE_EMAIL) ||
        $body === '' || strlen($body) > 5000
    ) {
        $httpStatus = 422;
        $message = 'Please check the submitted fields.';
    } else {
        $state = 'deferred';
        $metadata = null;

        try {
            $assessment = $validator->check($email);
            $state = $policy->decide($assessment);
            $metadata = json_encode(
                $assessment->toArray(),
                JSON_THROW_ON_ERROR
            );
        } catch (Throwable $error) {
            error_log(json_encode([
                'event' => 'email_validation_unavailable',
                'exception' => $error::class,
            ], JSON_THROW_ON_ERROR));
        }

        if ($state === 'rejected') {
            $httpStatus = 422;
            $message = 'Please provide another email address.';
        } else {
            $insert = $db->prepare(
                'INSERT INTO contacts
                 (id, name, email, message, validation_state,
                  api_metadata, created_at)
                 VALUES
                 (:id, :name, :email, :message, :state,
                  :metadata, :created)'
            );
            $insert->execute([
                'id' => bin2hex(random_bytes(16)),
                'name' => $name,
                'email' => $email,
                'message' => $body,
                'state' => $state,
                'metadata' => $metadata,
                'created' => gmdate(DATE_ATOM),
            ]);

            http_response_code(202);
            echo '<p>Thank you. Your message has been received.</p>';
            exit;
        }
    }
}

http_response_code($httpStatus);
$escape = static fn(string $value): string =>
    htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<?php if ($message !== ''): ?>
<p><?= $escape($message) ?></p>
<?php endif; ?>
<form method="post">
  <input type="hidden" name="csrf"
         value="<?= $escape($_SESSION['csrf']) ?>">
  <label>Name <input name="name" maxlength="120" required></label>
  <label>Email
    <input name="email" type="email" maxlength="254" required>
  </label>
  <label>Message
    <textarea name="message" maxlength="5000" required></textarea>
  </label>
  <button type="submit">Send</button>
</form>

Test caching and failure paths deterministically

Tests must not depend on network availability or consume quota. Making the transport non-final, or extracting an interface in a larger codebase, permits a deterministic fake. This test verifies that a cached result prevents a second HTTP request:

<?php
// tests/EmailValidatorTest.php
declare(strict_types=1);

namespace Tests;

use App\CurlTransport;
use App\EmailValidator;
use PHPUnit\Framework\TestCase;

final class FakeTransport extends CurlTransport
{
    public int $calls = 0;

    public function __construct(private array $response) {}

    public function get(string $endpoint, array $query): array
    {
        $this->calls++;
        return $this->response;
    }
}

final class EmailValidatorTest extends TestCase
{
    public function testValidResponseIsMappedAndCached(): void
    {
        $db = new \PDO('sqlite::memory:');
        $db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $db->exec(
            'CREATE TABLE email_cache (
                cache_key TEXT PRIMARY KEY,
                payload TEXT NOT NULL,
                expires_at INTEGER NOT NULL
            )'
        );

        $fake = new FakeTransport([
            'status' => 'documented-status',
            'score' => 80,
            'recommendation' => 'documented-recommendation',
            'checks' => ['syntax' => true, 'mx' => true],
            'quota' => ['remaining' => 10],
        ]);

        $validator = new EmailValidator(
            $db,
            $fake,
            'test-token',
            'test-cache-secret',
            3600,
        );

        $first = $validator->check('[email protected]');
        $second = $validator->check('[email protected]');

        self::assertSame('documented-status', $first->status);
        self::assertSame($first->toArray(), $second->toArray());
        self::assertSame(1, $fake->calls);
    }

    public function testMalformedResponseIsRejected(): void
    {
        $this->expectException(\UnexpectedValueException::class);
        \App\EmailAssessment::fromArray(['status' => 'incomplete']);
    }
}
vendor/bin/phpunit --testdox

Add focused tests for HTTP 401 without retry, a repeated 429, corrupt cache JSON, an explicit false check, an empty quota object, score boundaries, CSRF rejection, and the deferred database path. The fake response strings are deliberately test fixtures, not claims about production enum values.

Security, observability, and deployment

Terminate TLS at a trusted proxy, restrict request-body size, and add per-IP or per-session form throttling. Keep the API token, cache HMAC secret, database, and environment files outside the document root. Never place the full request URL in logs because its query string contains the token.

Useful structured log events include validation latency, HTTP status, retry count, cache hit or miss, final state, and exception class. Hash an address with the same keyed HMAC if correlation is necessary; do not log the raw address. Alert on sustained deferred rates, authentication failures, and quota-related 429 responses.

During deployment, create the schema before switching traffic, inject environment variables through the process manager or secret store, verify outbound HTTPS access, and confirm that the PHP user can write only to the intended SQLite files. Use WAL mode or migrate to a server database when concurrent write volume outgrows SQLite. Rotate the service token by updating all instances immediately after regeneration because the previous token is revoked.

Common failures and final verification

  • Every request is deferred: check DNS, outbound HTTPS, cURL certificates, timeouts, quota, and structured error logs.
  • Authentication fails: confirm that the service-scoped token is active and sent through the token query parameter.
  • Valid addresses are rejected: verify the configured status, recommendation, score scale, and the documented meaning of Boolean checks.
  • The cache never hits: confirm normalization, HMAC secret consistency across instances, writable storage, and a positive TTL.
  • SQLite reports locking errors: shorten transactions, enable WAL where appropriate, or move shared state to a database designed for concurrent writers.
  1. Submit a malformed address and confirm that no API request or contact row is created.
  2. Submit a documented acceptable case and confirm a verified row.
  3. Repeat it and confirm a cache hit without another remote call.
  4. Exercise a documented risky case and confirm a neutral 422 response.
  5. Disable outbound access and confirm the message is retained as deferred.
  6. Simulate malformed JSON, 401, 429, and 5xx responses in automated tests.
  7. Confirm that logs contain neither the service token nor raw email addresses.

The important production lesson is not merely to call an email-checking endpoint. It is to build a boundary around it: explicit policy, defensive mapping, bounded retries, privacy-aware caching, observable failure states, and a fallback that respects the visitor’s effort. With those pieces in place, a small contact form becomes substantially more reliable without becoming unnecessarily complicated.

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

Mihajlo

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