Нативен PHP 8.3: Побрзи контакт-формулари со паметна валидација на е-пошта и кеширање
Формуларот за контакт треба да биде пријатен за клиентите, а неисплатлив за ботовите. Самата валидација на синтаксата не може да утврди дали адресата има употреблив домен, MX записи, веродостојни сигнали од давателот или очигледен ризик при испорака. Но и целосното условување на секое поднесување од оддалечена услуга е друг вид грешка: проблемите со мрежата не треба тивко да отфрлат легитимно барање.
Ова упатство гради мало сандаче за контакти во Native PHP 8.3 околу таа тензија. Ги валидира адресите на е-пошта преку Email Validator API, ги кешира проценките во SQLite, внимателно ги повторува привремените неуспеси и ги испраќа неизвесните поднесувања на преглед наместо да ги изгуби.
Добијте пристап и копирајте го сервисниот токен
Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Email Validator.
- Изберете го моментално достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за Email Validator.
- Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.
- Зачувајте го токенот во конфигурација поддржана од променливи на околина. Неговото повторно генерирање го поништува претходниот активен токен, па распоредувањата што сè уште ја користат старата вредност мора да се ажурираат заедно.
Оваа услуга бара токен. Нејзиниот точен API повик е HTTP GET до https://ai.mihajlo.mk/api/email-validator/v1/check-email, со параметрите за пребарување email и token. Тестирајте го пристапот пред да пишувате код за апликацијата:
curl --silent --show-error \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email?email=person%40example.com&token=YOUR_SERVICE_TOKEN"
Не го вметнувајте одговорот во тикети ако содржи чувствителни податоци. Апликацијата одбранбено ќе ги обработува документираните полиња status, score, recommendation, checks и quota, без да претпоставува недокументирани enum вредности.
Native PHP не вчитува автоматски dotenv датотеки. За локален развој, чувајте некомитирана датотека со променливи на околина и нека вашата школка, менаџерот на процеси, контејнерската платформа или менаџерот на тајни ја внесе пред да се стартува PHP:
# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=/absolute/path/to/project/var/app.sqlite
# .gitignore
.env.local
var/*.sqlite
var/*.sqlite-*
Архитектура: дозволете неуспех, но означете ја неизвесноста
Патеката на барањето намерно останува синхрона бидејќи корисна проценка може да го запре очигледниот отпад пред да стигне до сандачето. Текот е:
- Локално отфрлете ги неправилно обликуваните адреси со
filter_var(). - Побарајте SHA-256 клуч за кеш, избегнувајќи необработени адреси на е-пошта во кешот.
- Повикајте го API преку изолиран транспорт кога не постои свеж запис.
- Мапирајте ги петте документирани полиња од одговорот во одлука на апликацијата.
- При привремен неуспех, повторете најмногу двапати со ограничено одложување.
- Користете неодамнешна застарена проценка ако е достапна; во спротивно, прифатете ја пораката во редица за преглед.
Политиката никогаш не толкува непознати низи за препорака или статус како измислени API enum вредности. Експлицитните Boolean неуспеси во status или checks предизвикуваат преглед; нумеричкиот резултат ги подредува поднесувањата за преглед; податоците за препораката и квотата остануваат прикачени на одлуката за операторите и дијагностиката. Само неуспехот на локалната синтакса целосно отфрла поднесување.
Создадете го проектот Native PHP
Предусловите се PHP 8.3 или понова верзија со cURL, PDO SQLite, JSON и Composer. PHPUnit 11 поддржува PHP 8.2 и понови верзии, што го прави соодветен тука.
mkdir -p contact-validator/{public,src,tests,var}
cd contact-validator
composer require --dev phpunit/phpunit:^11.0
Проектот содржи public/index.php, src/App.php, tests/EmailValidatorTest.php и SQLite базата на податоци за време на извршување во var/. Во продукција, само public/ треба да биде изложен од веб-серверот.
Изградете ја API границата, кешот и мапирањето на доменот
Транспортот пријавува само HTTP статус и тело. Ова ги држи деталите за cURL надвор од доменскиот код и ги прави неуспесите детерминистички во тестовите.
<?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
);
}
}
Маперот ги прифаќа полињата во коренот на одговорот или во објект data, а потоа ги валидира документираните полиња пред да влезат во апликацијата. Неправилно обликуван успешен одговор станува контролирана резервна опција наместо PHP известување или случајно одобрување.
Поврзете го валидаторот со формуларот за контакт
Предниот контролер ја создава базата на податоци, наметнува CSRF заштита, ја ограничува должината на внесот, ја валидира е-поштата и ги зачувува прифатените поднесувања или оние што може да се прегледаат. Заменете го основниот HTML со вашиот вообичаен систем на шаблони кога го интегрирате во постоечка страница.
<?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>
Тестирајте без да ја допирате надворешната услуга
Лажен транспорт ги прави повторувањата и мапирањето на одговорите репродуктивни. Токените и вистинските адреси на е-пошта никогаш не припаѓаат во фикстури.
<?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
Безбедност, набљудливост и распоредување
Бидејќи автентикацијата патува во низа за пребарување, никогаш не ја евидентирајте целосната URL-адреса на барањето. Каде што е можно, спречете логовите за пристап на веб-серверот да ги снимаат низите за пребарување за оваа рута. Имплементацијата евидентира само име на настан и категорија на неуспех, а не токен, е-пошта, тело на одговор или порака.
CSRF заштитата и honeypot-от ја дополнуваат валидацијата на е-пошта; ниту едното не ги заменува ограничувањата на големината на барањето или ограничувањето на стапката на рабниот слој. Екранирајте ја зачуваната содржина секогаш кога се прикажува. Ограничете ја SQLite датотеката на сметката на апликацијата, правете резервни копии според вредноста на барањата и никогаш не ја ставајте под public/.
За продукција, внесете ги двете вредности на околината преку тајната конфигурација на извршната околина, извршете composer install --no-dev --classmap-authoritative и конфигурирајте го коренот на документите како public/. Осигурете се дека PHP worker-от може да запишува во var/. Користете php -S 127.0.0.1:8080 -t public само за локална проверка, не како продукциски сервер.
Корисни метрики вклучуваат API латентност, статус на одговор, повторувања, погодоци во кешот, употреба на застарен кеш и број на резервни опции. Ненадејниот пораст на неуспесите при автентикација обично значи дека токенот недостасува, е поништен или е неправилно распореден. Долготрајните одговори 429 укажуваат на притисок од квота или стапка; зголемувањето на повторувањата би го засилило тој притисок, затоа наместо тоа прегледајте ја употребата, кеширањето и активниот план.
Вообичаени патеки на неуспех
- Одговори 401 или 403: проверете ја активацијата и токенот ограничен на услугата. Овие одговори не се повторуваат.
- Неправилно обликуван JSON или полиња што недостасуваат: третирајте го одговорот како деградиран и проверете ги исчистените логови; не нагаѓајте нова шема.
- Повторени 429 или серверски грешки: ограничениот буџет за повторување брзо завршува, потоа застарениот кеш или режимот за преглед го зачувува барањето.
- SQLite грешки „unable to open database“: проверете дали
APP_DBе апсолутна и дали нејзиниот родителски директориум е запишлив од PHP процесот. - Нема погодоци во кешот: потврдете дека нормализацијата е доследна и дека SQLite базата на податоци опстојува низ распоредувањата.
Конечна контролна листа за проверка
- Планот за услугата е активен, а токенот доаѓа од панелот Service token на страницата со документација.
- Токенот постои само во конфигурација поддржана од променливи на околина и може намерно да се ротира.
- Нормално поднесување ги зачувува сите пет документирани полиња за проценка.
- Повторена нормализирана адреса го користи кешот наместо друг API повик.
- Невалидната синтакса се отфрла локално без да троши оддалечена квота.
- Неуспесите при автентикација не се повторуваат; привремените неуспеси добиваат само ограничено одложување.
- Прекин на API сепак зачувува локално валидна порака со одлука
review. - Логовите содржат оперативни состојби, но не токен, адреса на е-пошта, порака или целосна URL-адреса на барање.
- Автоматизираните тестови поминуваат пред распоредувањето, а продукцискиот корен на документите е
public/.
Најсигурниот формулар за контакт не е оној што дава најсмело ветување за секоја адреса. Тој е оној што ја раздвојува сигурноста од неизвесноста: очигледните грешки се запираат, веродостојните сигнали се кешираат, оддалечените неуспеси се ограничуваат, а пораката на вистинска личност сè уште има безбедна патека до сандачето.