Izvorni PHP 8.3: Brži obrasci za kontakt s pametnom provjerom valjanosti e-pošte i predmemoriranjem
A contact form should be welcoming to customers and unrewarding to bots. Syntax validation alone cannot tell whether an address has a usable domain, MX records, credible provider signals, or obvious delivery risk. Yet making every submission depend completely on a remote service is another kind of mistake: network trouble should not silently discard a legitimate enquiry.
This tutorial builds a small Native PHP 8.3 contact inbox around that tension. It validates email addresses through the Email Validator API, caches assessments in SQLite, retries transient failures carefully, and sends uncertain submissions to review instead of losing them.
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 currently available Free, Plus, or Pro plan and complete its activation.
- Open the official Email Validator documentation.
- Find the Service token panel and copy its service-scoped token.
- Store the token in environment-backed configuration. Regenerating it revokes the previous active token, so deployments still using the old value must be updated together.
This service requires a token. Its exact API call is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email, with both email and token query parameters. Test access before writing application code:
curl --silent --show-error \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email?email=person%40example.com&token=YOUR_SERVICE_TOKEN"
Do not paste the response into tickets if it contains sensitive data. The application will defensively consume the documented status, score, recommendation, checks, and quota fields without assuming undocumented enum values.
Native PHP does not automatically load dotenv files. For local development, keep an uncommitted environment file and have your shell, process manager, container platform, or secret manager inject it before PHP starts:
# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=/absolute/path/to/project/var/app.sqlite
# .gitignore
.env.local
var/*.sqlite
var/*.sqlite-*
Architecture: fail open, but mark uncertainty
The request path remains deliberately synchronous because a useful assessment can stop obvious junk before it reaches the inbox. The flow is:
- Reject malformed addresses locally with
filter_var(). - Look up a SHA-256 cache key, avoiding raw email addresses in the cache.
- Call the API through an isolated transport when no fresh entry exists.
- Map the five documented response fields into an application decision.
- On transient failure, retry at most twice with bounded backoff.
- Use a recent stale assessment if available; otherwise accept the message into a review queue.
The policy never interprets unknown recommendation or status strings as invented API enums. Explicit Boolean failures in status or checks cause review; the numeric score orders reviewed submissions; recommendation and quota data remain attached to the decision for operators and diagnostics. Only local syntax failure rejects a submission outright.
Create the Native PHP project
Prerequisites are PHP 8.3 or later with cURL, PDO SQLite, JSON, and Composer. PHPUnit 11 supports PHP 8.2 and later, making it suitable here.
mkdir -p contact-validator/{public,src,tests,var}
cd contact-validator
composer require --dev phpunit/phpunit:^11.0
The project contains public/index.php, src/App.php, tests/EmailValidatorTest.php, and the runtime SQLite database under var/. In production, only public/ should be exposed by the web server.
Build the API boundary, cache, and domain mapping
The transport reports only an HTTP status and body. This keeps cURL details out of domain code and makes failures deterministic in tests.
<?php
// src/App.php
declare(strict_types=1);
final readonly class HttpResponse
{
public function __construct(public int $status, public string $body) {}
}
interface Transport
{
public function get(string $url, array $query): HttpResponse;
}
final class CurlTransport implements Transport
{
public function get(string $url, array $query): HttpResponse
{
$requestUrl = $url . '?' . http_build_query(
$query, '', '&', PHP_QUERY_RFC3986
);
$handle = curl_init($requestUrl);
if ($handle === false) {
throw new RuntimeException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 1500,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($handle);
if ($body === false) {
$errorNumber = curl_errno($handle);
curl_close($handle);
throw new RuntimeException("Email API transport error {$errorNumber}");
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body);
}
}
final readonly class EmailDecision
{
public function __construct(
public string $state,
public ?float $score,
public string $recommendation,
public array $checks,
public mixed $quota,
public string $source
) {}
public function metadata(): array
{
return [
'state' => $this->state,
'score' => $this->score,
'recommendation' => $this->recommendation,
'checks' => $this->checks,
'quota' => $this->quota,
'source' => $this->source,
];
}
}
final class DecisionCache
{
public function __construct(private PDO $db)
{
$db->exec(
'CREATE TABLE IF NOT EXISTS email_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
)'
);
}
public function get(string $key, bool $stale = false): ?array
{
$minimum = time() - ($stale ? 86400 : 0);
$query = $this->db->prepare(
'SELECT payload FROM email_cache
WHERE cache_key = :key AND expires_at >= :minimum'
);
$query->execute(['key' => $key, 'minimum' => $minimum]);
$payload = $query->fetchColumn();
if (!is_string($payload)) {
return null;
}
try {
$decoded = json_decode($payload, true, 32, JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : null;
} catch (JsonException) {
return null;
}
}
public function put(string $key, array $payload): void
{
$query = $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'
);
$query->execute([
'key' => $key,
'payload' => json_encode($payload, JSON_THROW_ON_ERROR),
'expires' => time() + 3600,
]);
}
}
final class EmailValidator
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private Transport $transport,
private DecisionCache $cache,
private string $token,
private Closure $logger,
private Closure $sleep
) {}
public function assess(string $email): EmailDecision
{
$normalized = strtolower(trim($email));
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
return new EmailDecision(
'reject', null, 'Local syntax validation failed', [], null, 'local'
);
}
$key = hash('sha256', $normalized);
if ($cached = $this->cache->get($key)) {
return $this->map($cached, 'cache');
}
$lastFailure = 'unknown';
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->transport->get(self::ENDPOINT, [
'email' => $normalized,
'token' => $this->token,
]);
if ($response->status === 200) {
$payload = json_decode(
$response->body, true, 32, JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new UnexpectedValueException('Non-object response');
}
$data = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
$decision = $this->map($data, 'api');
$this->cache->put($key, $data);
return $decision;
}
$lastFailure = "http_{$response->status}";
$retryable = $response->status === 408
|| $response->status === 429
|| $response->status >= 500;
if (!$retryable) {
break; // Includes validation and authentication failures.
}
} catch (Throwable $error) {
$lastFailure = $error::class;
}
if ($attempt < 2) {
($this->sleep)(100000 * (2 ** $attempt));
}
}
($this->logger)([
'event' => 'email_validation_degraded',
'failure' => $lastFailure,
]);
if ($stale = $this->cache->get($key, true)) {
return $this->map($stale, 'stale-cache');
}
return new EmailDecision(
'review', null, 'Remote validation unavailable', [], null, 'fallback'
);
}
private function map(array $payload, string $source): EmailDecision
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (!array_key_exists($field, $payload)) {
throw new UnexpectedValueException("Missing response field: {$field}");
}
}
if (!is_numeric($payload['score'])
|| !is_array($payload['checks'])
|| !is_scalar($payload['recommendation'])) {
throw new UnexpectedValueException('Invalid response field types');
}
$failedCheck = in_array(false, $payload['checks'], true);
$state = $payload['status'] === false || $failedCheck
? 'review'
: 'accept';
return new EmailDecision(
$state,
(float) $payload['score'],
(string) $payload['recommendation'],
$payload['checks'],
$payload['quota'],
$source
);
}
}
The mapper accepts fields at the response root or inside a data object, then validates the documented fields before they enter the application. A malformed success response becomes a controlled fallback rather than a PHP notice or an accidental approval.
Connect the validator to the contact form
The front controller creates the database, enforces CSRF protection, limits input length, validates the email, and stores accepted or reviewable submissions. Replace the basic HTML with your normal template system when integrating it into an existing site.
<?php
// public/index.php
declare(strict_types=1);
session_start();
require dirname(__DIR__) . '/src/App.php';
$token = getenv('EMAIL_VALIDATOR_TOKEN');
$dbPath = getenv('APP_DB');
if (!is_string($token) || $token === '' || !is_string($dbPath) || $dbPath === '') {
http_response_code(500);
exit('Application configuration is incomplete.');
}
$db = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$db->exec(
'CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
message TEXT NOT NULL,
decision TEXT NOT NULL,
assessment TEXT NOT NULL,
created_at TEXT NOT NULL
)'
);
$logger = static function (array $record): void {
error_log(json_encode($record, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
};
$validator = new EmailValidator(
new CurlTransport(),
new DecisionCache($db),
$token,
$logger,
static fn (int $microseconds) => usleep($microseconds)
);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$notice = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = (string) ($_POST['csrf'] ?? '');
$honeypot = (string) ($_POST['website'] ?? '');
$email = trim((string) ($_POST['email'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
if (!hash_equals($_SESSION['csrf'], $csrf) || $honeypot !== '') {
http_response_code(400);
$notice = 'The request could not be accepted.';
} elseif (mb_strlen($email) > 254
|| $message === ''
|| mb_strlen($message) > 5000) {
http_response_code(422);
$notice = 'Check the email address and message length.';
} else {
$decision = $validator->assess($email);
if ($decision->state === 'reject') {
http_response_code(422);
$notice = 'Enter a valid email address.';
} else {
$insert = $db->prepare(
'INSERT INTO contacts
(email, message, decision, assessment, created_at)
VALUES (:email, :message, :decision, :assessment, :created)'
);
$insert->execute([
'email' => $email,
'message' => $message,
'decision' => $decision->state,
'assessment' => json_encode(
$decision->metadata(), JSON_THROW_ON_ERROR
),
'created' => gmdate('c'),
]);
$_SESSION['csrf'] = bin2hex(random_bytes(32));
$notice = $decision->state === 'review'
? 'Thanks. Your message was received for review.'
: 'Thanks. Your message was received.';
}
}
}
$escape = static fn (string $value): string =>
htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<!doctype html>
<html lang="en"><body>
<p><?= $escape($notice) ?></p>
<form method="post">
<input type="hidden" name="csrf" value="<?= $escape($_SESSION['csrf']) ?>">
<input name="website" tabindex="-1" autocomplete="off" hidden>
<label>Email <input type="email" name="email" maxlength="254" required></label>
<label>Message <textarea name="message" maxlength="5000" required></textarea></label>
<button type="submit">Send</button>
</form>
</body></html>
Test without touching the external service
A fake transport makes retries and response mapping reproducible. Tokens and real email addresses never belong in fixtures.
<?php
// tests/EmailValidatorTest.php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require dirname(__DIR__) . '/src/App.php';
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, array $query): HttpResponse
{
$this->calls++;
$next = array_shift($this->responses);
if ($next instanceof Throwable) {
throw $next;
}
return $next;
}
}
final class EmailValidatorTest extends TestCase
{
private function cache(): DecisionCache
{
$db = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
return new DecisionCache($db);
}
public function testMapsAndCachesAValidResponse(): void
{
$body = json_encode([
'status' => true,
'score' => 91,
'recommendation' => 'fixture recommendation',
'checks' => ['syntax' => true, 'mx' => true],
'quota' => ['fixture' => 1],
], JSON_THROW_ON_ERROR);
$transport = new FakeTransport([new HttpResponse(200, $body)]);
$validator = new EmailValidator(
$transport, $this->cache(), 'fake-token',
static fn (array $record) => null,
static fn (int $delay) => null
);
self::assertSame('accept', $validator->assess('[email protected]')->state);
self::assertSame('cache', $validator->assess('[email protected]')->source);
self::assertSame(1, $transport->calls);
}
public function testFallsBackAfterThreeTransientFailures(): void
{
$transport = new FakeTransport([
new HttpResponse(503, ''),
new HttpResponse(429, ''),
new RuntimeException('network down'),
]);
$validator = new EmailValidator(
$transport, $this->cache(), 'fake-token',
static fn (array $record) => null,
static fn (int $delay) => null
);
self::assertSame(
'review',
$validator->assess('[email protected]')->state
);
self::assertSame(3, $transport->calls);
}
public function testRejectsBadSyntaxWithoutCallingTransport(): void
{
$transport = new FakeTransport([]);
$validator = new EmailValidator(
$transport, $this->cache(), 'fake-token',
static fn (array $record) => null,
static fn (int $delay) => null
);
self::assertSame('reject', $validator->assess('not-an-email')->state);
self::assertSame(0, $transport->calls);
}
}
vendor/bin/phpunit tests
php -l src/App.php
php -l public/index.php
Security, observability, and deployment
Because authentication travels in the query string, never log the complete request URL. Keep web-server access logs from recording query strings for this route where possible. The implementation logs only an event name and failure category, not the token, email, response body, or message.
CSRF protection and the honeypot complement email validation; neither replaces request-size limits or edge rate limiting. Escape stored content whenever it is displayed. Restrict the SQLite file to the application account, back it up according to the value of the enquiries, and never place it under public/.
For production, inject the two environment values through the runtime’s secret configuration, run composer install --no-dev --classmap-authoritative, and configure the document root as public/. Ensure the PHP worker can write to var/. Use php -S 127.0.0.1:8080 -t public only for local verification, not as a production server.
Useful metrics include API latency, response status, retries, cache hits, stale-cache use, and fallback count. A sudden rise in authentication failures usually means a missing, revoked, or incorrectly deployed token. Sustained 429 responses indicate quota or rate pressure; increasing retries would amplify that pressure, so review usage, caching, and the active plan instead.
Common failure paths
- 401 or 403 responses: verify activation and the service-scoped token. These responses are not retried.
- Malformed JSON or missing fields: treat the response as degraded and inspect sanitized logs; do not guess a new schema.
- Repeated 429 or server errors: the bounded retry budget ends quickly, then stale cache or review mode preserves the enquiry.
- SQLite “unable to open database” errors: check that
APP_DBis absolute and its parent directory is writable by the PHP process. - No cache hits: confirm normalization is consistent and that the SQLite database persists across deployments.
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 can be rotated deliberately.
- A normal submission stores all five documented assessment fields.
- A repeated normalized address uses the cache rather than another API call.
- Invalid syntax is rejected locally without consuming remote quota.
- Authentication failures are not retried; transient failures receive only bounded backoff.
- An API outage still stores a locally valid message with a
reviewdecision. - Logs contain operational states but no token, email address, message, or complete request URL.
- Automated tests pass before deployment, and the production document root is
public/.
The most reliable contact form is not the one that makes the boldest promise about every address. It is the one that separates certainty from uncertainty: obvious mistakes are stopped, credible signals are cached, remote failures are contained, and a real person’s message still has a safe path into the inbox.