Vodiči

Native PHP 8.3: Faster Contact Forms with Smart Email Validation and Caching

Izvorni PHP 8.3: Brži obrasci za kontakt s pametnom provjerom valjanosti e-pošte i predmemoriranjem

Kontaktni obrazac trebao bi biti ugodan korisnicima, a nezahvalan botovima. Sama provjera sintakse ne može utvrditi ima li adresa upotrebljivu domenu, MX zapise, vjerodostojne signale pružatelja usluge ili očit rizik isporuke. No potpuna ovisnost svake prijave o udaljenoj usluzi druga je vrsta pogreške: mrežni problemi ne bi smjeli tiho odbaciti legitimni upit.

Ovaj vodič izrađuje mali Native PHP 8.3 ulazni sandučić za kontaktne poruke upravo oko te napetosti. Provjerava adrese e-pošte putem API-ja Email Validator, predmemorira procjene u SQLiteu, pažljivo ponovno pokušava pri prolaznim neuspjesima i šalje nesigurne prijave na pregled umjesto da ih izgubi.

Pristupite usluzi i kopirajte token usluge

Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.

  1. Otvorite stranicu usluge Email Validator.
  2. Odaberite trenutno dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju za Email Validator.
  4. Pronađite panel Service token i kopirajte njegov token ograničen na uslugu.
  5. Pohranite token u konfiguraciju koja se temelji na varijablama okruženja. Njegovo ponovno generiranje opoziva prethodni aktivni token, stoga se implementacije koje i dalje koriste staru vrijednost moraju ažurirati zajedno.

Ova usluga zahtijeva token. Njezin je točan API poziv HTTP GET na https://ai.mihajlo.mk/api/email-validator/v1/check-email, s parametrima upita email i token. Testirajte pristup prije pisanja aplikacijskog koda:

curl --silent --show-error \
  "https://ai.mihajlo.mk/api/email-validator/v1/check-email?email=person%40example.com&token=YOUR_SERVICE_TOKEN"

Nemojte lijepiti odgovor u tikete ako sadrži osjetljive podatke. Aplikacija će obrambeno upotrebljavati dokumentirana polja status, score, recommendation, checks i quota bez pretpostavljanja nedokumentiranih enum vrijednosti.

Native PHP ne učitava automatski dotenv datoteke. Za lokalni razvoj zadržite nepredanu datoteku okruženja i neka je vaša ljuska, upravitelj procesa, platforma kontejnera ili upravitelj tajni umetne prije pokretanja PHP-a:

# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=/absolute/path/to/project/var/app.sqlite
# .gitignore
.env.local
var/*.sqlite
var/*.sqlite-*

Arhitektura: dopustite neuspjeh, ali označite nesigurnost

Putanja zahtjeva namjerno ostaje sinkrona jer korisna procjena može zaustaviti očiti neželjeni sadržaj prije nego što stigne u ulazni sandučić. Tijek je sljedeći:

  1. Lokalno odbacite neispravno oblikovane adrese pomoću filter_var().
  2. Potražite SHA-256 ključ predmemorije, izbjegavajući neobrađene adrese e-pošte u predmemoriji.
  3. Pozovite API putem izoliranog prijenosa ako ne postoji svježi unos.
  4. Preslikajte pet dokumentiranih polja odgovora u odluku aplikacije.
  5. Pri prolaznom neuspjehu ponovno pokušajte najviše dvaput uz ograničeni backoff.
  6. Upotrijebite nedavnu zastarjelu procjenu ako je dostupna; u suprotnom prihvatite poruku u red za pregled.

Pravilo nikada ne tumači nepoznate nizove preporuke ili statusa kao izmišljene API enum vrijednosti. Izričiti Booleovi neuspjesi u status ili checks uzrokuju pregled; numerički rezultat određuje redoslijed pregledanih prijava; podaci o preporuci i kvoti ostaju pridruženi odluci za operatere i dijagnostiku. Samo neuspjeh lokalne sintakse izravno odbacuje prijavu.

Izradite Native PHP projekt

Preduvjeti su PHP 8.3 ili noviji s cURL-om, PDO SQLiteom, JSON-om i Composerom. PHPUnit 11 podržava PHP 8.2 i noviji, što ga ovdje čini prikladnim.

mkdir -p contact-validator/{public,src,tests,var}
cd contact-validator
composer require --dev phpunit/phpunit:^11.0

Projekt sadrži public/index.php, src/App.php, tests/EmailValidatorTest.php i runtime SQLite bazu podataka u direktoriju var/. U produkciji web-poslužitelj bi trebao izlagati samo public/.

Izradite API granicu, predmemoriju i preslikavanje domene

Prijenos prijavljuje samo HTTP status i tijelo odgovora. Time se pojedinosti cURL-a drže izvan domenskog koda, a neuspjesi su deterministički u testovima.

<?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
        );
    }
}

Preslikavač prihvaća polja u korijenu odgovora ili unutar objekta data, a zatim provjerava dokumentirana polja prije nego što uđu u aplikaciju. Neispravno oblikovan uspješan odgovor postaje kontrolirani rezervni postupak umjesto PHP obavijesti ili slučajnog odobrenja.

Povežite validator s kontaktnim obrascem

Prednji kontroler stvara bazu podataka, provodi CSRF zaštitu, ograničava duljinu unosa, provjerava e-poštu te pohranjuje prihvaćene prijave ili prijave koje se mogu pregledati. Zamijenite osnovni HTML svojim uobičajenim sustavom predložaka kada ga integrirate u postojeću stranicu.

<?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>

Testirajte bez dodirivanja vanjske usluge

Lažni prijenos čini ponovne pokušaje i preslikavanje odgovora ponovljivima. Tokeni i stvarne adrese e-pošte nikada ne pripadaju testnim podacima.

<?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

Sigurnost, vidljivost i implementacija

Budući da se autentikacija prenosi u nizu upita, nikada nemojte bilježiti potpuni URL zahtjeva. Gdje je moguće, spriječite da zapisnici pristupa web-poslužitelja bilježe nizove upita za ovu rutu. Implementacija bilježi samo naziv događaja i kategoriju neuspjeha, a ne token, e-poštu, tijelo odgovora ni poruku.

CSRF zaštita i honeypot nadopunjuju provjeru e-pošte; nijedno ne zamjenjuje ograničenja veličine zahtjeva ni ograničavanje stope na rubu mreže. Izbjegnite pohranjeni sadržaj svaki put kada se prikazuje. Ograničite SQLite datoteku na aplikacijski račun, sigurnosno je kopirajte prema vrijednosti upita i nikada je ne postavljajte pod public/.

Za produkciju umetnite dvije vrijednosti okruženja putem konfiguracije tajni izvršnog okruženja, pokrenite composer install --no-dev --classmap-authoritative i konfigurirajte korijen dokumenta kao public/. Provjerite može li PHP radnik pisati u var/. Upotrijebite php -S 127.0.0.1:8080 -t public samo za lokalnu provjeru, a ne kao produkcijski poslužitelj.

Korisne metrike uključuju latenciju API-ja, status odgovora, ponovne pokušaje, pogotke predmemorije, upotrebu zastarjele predmemorije i broj rezervnih postupaka. Nagli porast neuspjeha autentikacije obično znači da token nedostaje, da je opozvan ili nepravilno implementiran. Trajni odgovori 429 ukazuju na pritisak kvote ili stope; povećanje broja ponovnih pokušaja pojačalo bi taj pritisak, stoga umjesto toga provjerite upotrebu, predmemoriranje i aktivni paket.

Uobičajeni putovi neuspjeha

  • Odgovori 401 ili 403: provjerite aktivaciju i token ograničen na uslugu. Ti se odgovori ne pokušavaju ponovno.
  • Neispravno oblikovan JSON ili polja koja nedostaju: tretirajte odgovor kao degradiran i pregledajte anonimizirane zapisnike; ne nagađajte novu shemu.
  • Ponovljene pogreške 429 ili pogreške poslužitelja: ograničeni proračun za ponovne pokušaje brzo završava, nakon čega zastarjela predmemorija ili način pregleda čuvaju upit.
  • SQLite pogreške „unable to open database”: provjerite je li APP_DB apsolutna putanja i je li njezin nadređeni direktorij upisiv za PHP proces.
  • Nema pogodaka predmemorije: potvrdite da je normalizacija dosljedna i da SQLite baza podataka opstaje između implementacija.

Završni kontrolni popis za provjeru

  • Paket usluge je aktivan, a token dolazi iz panela Service token na stranici dokumentacije.
  • Token postoji samo u konfiguraciji koja se temelji na varijablama okruženja i može se namjerno rotirati.
  • Uobičajena prijava pohranjuje svih pet dokumentiranih polja procjene.
  • Ponovljena normalizirana adresa upotrebljava predmemoriju umjesto drugog API poziva.
  • Nevaljana sintaksa lokalno se odbacuje bez trošenja udaljene kvote.
  • Neuspjesi autentikacije ne pokušavaju se ponovno; prolazni neuspjesi primaju samo ograničeni backoff.
  • Prekid rada API-ja i dalje pohranjuje lokalno valjanu poruku s odlukom review.
  • Zapisnici sadržavaju operativna stanja, ali ne token, adresu e-pošte, poruku ni potpuni URL zahtjeva.
  • Automatizirani testovi prolaze prije implementacije, a produkcijski korijen dokumenta je public/.

Najpouzdaniji kontaktni obrazac nije onaj koji daje najsmjelije obećanje za svaku adresu. To je onaj koji odvaja sigurnost od nesigurnosti: očite se pogreške zaustavljaju, vjerodostojni se signali predmemoriraju, udaljeni se neuspjesi obuzdavaju, a poruka stvarne osobe i dalje ima siguran put u ulazni sandučić.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.