Туториали

Native PHP: Robust Registration Forms with AI Email Validation Fallbacks

Нативен PHP: Надежни формуляри за регистрација со резервни опции за валидација на е-пошта со ВИ

Формуларот за регистрација има две задачи што повремено влечат во спротивни насоки: да ги задржи ризичните адреси надвор и да им овозможи пристап на легитимните луѓе. Оддалечената услуга за валидација на е-пошта ја подобрува првата задача, но мрежен истек на време, исцрпена квота или привремен проблем кај надворешната услуга не смее да ја наруши втората.

Овој туторијал создава тек за регистрација во Native PHP 8.3 околу тој принцип. Успешната валидација може веднаш да активира сметка. Неубедлив резултат од валидацијата може да ја задржи за преглед. Привремен неуспех на API ја создава сметката со одложена валидација, зачувувајќи ја достапноста без да се преправа дека проверката успеала.

Добијте пристап и копирајте го токенот на услугата

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Email Validator.
  3. Изберете го достапниот Free, Plus или Pro план и довршете ја неговата активација.
  4. Отворете ја официјалната документација за Email Validator.
  5. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

Оваа услуга бара токен. Неговото повторно генерирање го поништува претходно активниот токен, затоа третирајте ја ротацијата како промена при распоредување: навремено ажурирајте ја околината на апликацијата и потврдете ја новата ингеренција пред да отстраните какво било оперативно предупредување.

Потврдете го договорот на API пред да ја изградите функционалноста

Точниот повик е HTTP GET кон https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата го користи параметарот за пребарување token, додека адресата се испраќа во email.

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

Одговорот обезбедува status, score, recommendation, checks и quota. Услугата ги разгледува синтаксата, информациите за доменот, MX-записите, сигналите од давателот и практичниот ризик за испорака.

Не го претпоставувајте значењето на недокументирани литерали за препорака, опсези на резултати или имиња на проверки. Прегледајте ја документацијата на активираниот план и тест-одговорот, а потоа конфигурирајте ја политиката на апликацијата со документираните вредности. Тоа раздвојување го задржува речникот на услугата надвор од доменскиот код.

Зачувајте ги ингеренцијата и политиката во датотека со променливи на околината надвор од јавниот директориум:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_SUCCESS_STATUS=YOUR_DOCUMENTED_SUCCESS_STATUS
EMAIL_VALIDATOR_ALLOW_RECOMMENDATIONS=YOUR_DOCUMENTED_ALLOW_RECOMMENDATION
EMAIL_VALIDATOR_SCORE_MIN=YOUR_DOCUMENTED_MINIMUM_APPROVAL_SCORE
EMAIL_VALIDATOR_SCORE_MAX=YOUR_DOCUMENTED_MAXIMUM_APPROVAL_SCORE
EMAIL_VALIDATOR_REQUIRED_CHECKS_JSON={"YOUR_DOCUMENTED_CHECK_NAME":true}

Заменете го секое место за пополнување пред распоредувањето. Никогаш не ја предавајте оваа датотека во репозиториум и не го ставајте токенот во изворен код, дневници, тест-фикстури, URL-адреси прикажани на корисници или слики од екранот.

Структура на проектот и модел на одлучување

Потребен ви е PHP 8.3 или понов со поддршка за cURL, PDO, JSON и OpenSSL, како и Composer. Овој пример користи SQLite за скромна апликација на еден хост и PHPUnit 11 за тестови.

composer require --dev phpunit/phpunit:^11.0
mkdir -p public src tests var

# Project layout:
# public/index.php
# src/HttpTransport.php
# src/CurlTransport.php
# src/EmailValidatorClient.php
# src/RegistrationPolicy.php
# tests/EmailValidatorClientTest.php
# schema.sql

Додајте PSR-4 автоматско вчитување во composer.json и извршете composer dump-autoload:

{
  "require": {
    "php": "^8.3"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

Политиката има три исходи:

  • Активна и потврдена: исполнет е секој конфигуриран услов на API.
  • Во очекување на преглед: API одговори правилно, но неговите докази не ја исполнија политиката за активација.
  • Активна со одложена валидација: неуспех при транспортот, автентикацијата, ограничувањето на стапката или обликот на одговорот спречи доверлива одлука. Корисникот не е одбиен затоа што инфраструктурата не успеала.

Изолирајте го HTTP транспортот од семантиката на API

Тесен транспортен интерфејс го прави клиентот детерминистички во тестовите. Native cURL ја задржува TLS-проверката, користи ограничени истеци на време и никогаш не ја запишува URL-адресата во дневник бидејќи ги содржи и токенот и адресата на е-пошта.

<?php
// src/HttpTransport.php
namespace App;

interface HttpTransport
{
    public function get(string $url): HttpResponse;
}

final readonly class HttpResponse
{
    public function __construct(
        public int $statusCode,
        public string $body,
    ) {}
}

final class TransportException extends \RuntimeException {}
<?php
// src/CurlTransport.php
namespace App;

final class CurlTransport implements HttpTransport
{
    public function get(string $url): HttpResponse
    {
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_CONNECTTIMEOUT_MS => 2000,
            CURLOPT_TIMEOUT_MS => 5000,
            CURLOPT_FOLLOWLOCATION => false,
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportException($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $body);
    }
}

Претворете го оддалечениот одговор во доменски резултат

Клиентот ги разликува неуспесите наместо да враќа нејасна булова вредност. Тој еднаш повторува при неуспех на транспортот или HTTP 502, 503 или 504, со кратко случајно одложување. Не повторува при неуспеси во автентикацијата, невалидни барања или HTTP 429: непосредното повторување не може да поправи лоши ингеренции и може да го влоши притисокот врз квотата.

<?php
// src/EmailValidatorClient.php
namespace App;

enum FailureKind: string
{
    case Transport = 'transport';
    case Authentication = 'authentication';
    case RateLimited = 'rate_limited';
    case InvalidRequest = 'invalid_request';
    case InvalidResponse = 'invalid_response';
    case Upstream = 'upstream';
}

final readonly class ValidationData
{
    public function __construct(
        public bool|string|int|float $status,
        public float $score,
        public string $recommendation,
        public array $checks,
        public array $quota,
    ) {}
}

final readonly class ValidationResult
{
    private function __construct(
        public ?ValidationData $data,
        public ?FailureKind $failure,
    ) {}

    public static function verified(ValidationData $data): self
    {
        return new self($data, null);
    }

    public static function failed(FailureKind $failure): self
    {
        return new self(null, $failure);
    }

    public function isVerified(): bool
    {
        return $this->data !== null;
    }
}

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

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        private readonly \Closure $sleep,
    ) {}

    public function check(string $email): ValidationResult
    {
        $url = self::ENDPOINT . '?' . http_build_query(
            ['token' => $this->token, 'email' => $email],
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        for ($attempt = 0; $attempt < 2; $attempt++) {
            try {
                $response = $this->transport->get($url);
            } catch (TransportException) {
                if ($attempt === 0) {
                    ($this->sleep)(random_int(150000, 300000));
                    continue;
                }

                return ValidationResult::failed(FailureKind::Transport);
            }

            if (in_array($response->statusCode, [502, 503, 504], true)) {
                if ($attempt === 0) {
                    ($this->sleep)(random_int(150000, 300000));
                    continue;
                }

                return ValidationResult::failed(FailureKind::Upstream);
            }

            return $this->map($response);
        }

        return ValidationResult::failed(FailureKind::Upstream);
    }

    private function map(HttpResponse $response): ValidationResult
    {
        if (in_array($response->statusCode, [401, 403], true)) {
            return ValidationResult::failed(FailureKind::Authentication);
        }

        if ($response->statusCode === 429) {
            return ValidationResult::failed(FailureKind::RateLimited);
        }

        if ($response->statusCode >= 400 && $response->statusCode < 500) {
            return ValidationResult::failed(FailureKind::InvalidRequest);
        }

        if ($response->statusCode !== 200) {
            return ValidationResult::failed(FailureKind::Upstream);
        }

        try {
            $body = json_decode($response->body, true, 32, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return ValidationResult::failed(FailureKind::InvalidResponse);
        }

        $required = ['status', 'score', 'recommendation', 'checks', 'quota'];
        foreach ($required as $field) {
            if (!is_array($body) || !array_key_exists($field, $body)) {
                return ValidationResult::failed(FailureKind::InvalidResponse);
            }
        }

        $validStatus = is_bool($body['status'])
            || is_string($body['status'])
            || is_int($body['status'])
            || is_float($body['status']);

        if (
            !$validStatus
            || !is_int($body['score']) && !is_float($body['score'])
            || !is_string($body['recommendation'])
            || !is_array($body['checks'])
            || !is_array($body['quota'])
        ) {
            return ValidationResult::failed(FailureKind::InvalidResponse);
        }

        return ValidationResult::verified(new ValidationData(
            $body['status'],
            (float) $body['score'],
            $body['recommendation'],
            $body['checks'],
            $body['quota'],
        ));
    }
}

Претворете ги доказите во одлука за сметката

Политиката ги користи сите пет области на одговорот. Таа ги споредува документираните статус и препорака, го применува конфигурираниот интервал на резултат, ги споредува избраните проверки со нивните очекувани вредности и бара да постојат метаподатоци за квотата. Непознатите полиња остануваат безопасни, додека полињата што недостигаат или се структурно невалидни веќе беа одбиени на границата.

<?php
// src/RegistrationPolicy.php
namespace App;

enum AccountMode: string
{
    case Active = 'active';
    case Pending = 'pending';
}

final readonly class RegistrationDecision
{
    public function __construct(
        public AccountMode $mode,
        public string $validationState,
        public string $reason,
    ) {}
}

final readonly class RegistrationPolicy
{
    public function __construct(
        private string $successStatus,
        private array $allowedRecommendations,
        private float $minimumScore,
        private float $maximumScore,
        private array $requiredChecks,
    ) {}

    public function decide(ValidationResult $result): RegistrationDecision
    {
        if (!$result->isVerified()) {
            return new RegistrationDecision(
                AccountMode::Active,
                'deferred',
                $result->failure?->value ?? 'unknown_failure'
            );
        }

        $data = $result->data;
        $checksMatch = true;

        foreach ($this->requiredChecks as $name => $expected) {
            if (
                !array_key_exists($name, $data->checks)
                || $data->checks[$name] !== $expected
            ) {
                $checksMatch = false;
                break;
            }
        }

        $approved =
            self::scalar($data->status) === $this->successStatus
            && in_array(
                $data->recommendation,
                $this->allowedRecommendations,
                true
            )
            && $data->score >= $this->minimumScore
            && $data->score <= $this->maximumScore
            && $checksMatch
            && $data->quota !== [];

        return $approved
            ? new RegistrationDecision(AccountMode::Active, 'verified', 'approved')
            : new RegistrationDecision(AccountMode::Pending, 'review', 'policy_mismatch');
    }

    private static function scalar(bool|string|int|float $value): string
    {
        return is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
    }
}

Безбедно зачувајте ја регистрацијата

Создадете ја базата на податоци еднаш:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    account_mode TEXT NOT NULL,
    validation_state TEXT NOT NULL,
    validation_reason TEXT NOT NULL,
    created_at TEXT NOT NULL
);

POST-обработувачот треба да изврши CSRF-проверка, евтина локална валидација, оддалечена валидација и параметризиран вметнувачки упит. Истиот неутрален одговор за нови и дупликат адреси ја намалува можноста за набројување сметки.

<?php
// Core POST path from public/index.php
declare(strict_types=1);

use App\{
    AccountMode, CurlTransport, EmailValidatorClient, RegistrationPolicy
};

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

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
    $token = htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8');
    echo '<form method="post"><input type="hidden" name="csrf" value="'
        . $token
        . '"><input type="email" name="email" required>'
        . '<input type="password" name="password" minlength="12" required>'
        . '<button type="submit">Register</button></form>';
    exit;
}

if (
    !isset($_POST['csrf'], $_SESSION['csrf'])
    || !hash_equals($_SESSION['csrf'], (string) $_POST['csrf'])
) {
    http_response_code(419);
    exit('Request expired. Reload the form.');
}

$email = trim((string) ($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');

if (
    strlen($email) > 254
    || filter_var($email, FILTER_VALIDATE_EMAIL) === false
    || strlen($password) < 12
) {
    http_response_code(422);
    exit('Check the submitted details.');
}

$required = static function (string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '' || str_contains($value, 'YOUR_')) {
        throw new RuntimeException("Missing configuration: {$name}");
    }
    return $value;
};

$checks = json_decode(
    $required('EMAIL_VALIDATOR_REQUIRED_CHECKS_JSON'),
    true,
    16,
    JSON_THROW_ON_ERROR
);

$client = new EmailValidatorClient(
    new CurlTransport(),
    $required('EMAIL_VALIDATOR_TOKEN'),
    static fn (int $microseconds) => usleep($microseconds)
);

$policy = new RegistrationPolicy(
    $required('EMAIL_VALIDATOR_SUCCESS_STATUS'),
    array_map('trim', explode(
        ',',
        $required('EMAIL_VALIDATOR_ALLOW_RECOMMENDATIONS')
    )),
    (float) $required('EMAIL_VALIDATOR_SCORE_MIN'),
    (float) $required('EMAIL_VALIDATOR_SCORE_MAX'),
    $checks
);

$started = hrtime(true);
$result = $client->check($email);
$decision = $policy->decide($result);

error_log(json_encode([
    'event' => 'email_validation_completed',
    'account_mode' => $decision->mode->value,
    'validation_state' => $decision->validationState,
    'reason' => $decision->reason,
    'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
], JSON_THROW_ON_ERROR));

$pdo = new PDO('sqlite:' . dirname(__DIR__) . '/var/app.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$statement = $pdo->prepare(
    'INSERT OR IGNORE INTO users
     (email, password_hash, account_mode, validation_state,
      validation_reason, created_at)
     VALUES (:email, :password_hash, :account_mode, :validation_state,
             :validation_reason, :created_at)'
);

$statement->execute([
    'email' => $email,
    'password_hash' => password_hash($password, PASSWORD_DEFAULT),
    'account_mode' => $decision->mode->value,
    'validation_state' => $decision->validationState,
    'validation_reason' => $decision->reason,
    'created_at' => gmdate(DATE_ATOM),
]);

unset($_SESSION['csrf']);
http_response_code(202);
echo $decision->mode === AccountMode::Pending
    ? 'Registration received and awaiting review.'
    : 'Registration received.';

Тестирајте ги повторувањата и границата за отворено продолжување при неуспех

Лажниот транспорт не содржи мрежно однесување ниту вистинска ингеренција. Неговите литерали се фикстури контролирани од апликацијата, а не тврдења за продукцискиот речник на услугата.

<?php
// tests/EmailValidatorClientTest.php
use App\{
    EmailValidatorClient, FailureKind, HttpResponse, HttpTransport,
    RegistrationPolicy, AccountMode
};
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(string $url): HttpResponse
    {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class EmailValidatorClientTest extends TestCase
{
    public function testRetriesOneTransientFailure(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(503, ''),
            new HttpResponse(200, json_encode([
                'status' => 'fixture-success',
                'score' => 80,
                'recommendation' => 'fixture-allow',
                'checks' => ['fixture-check' => true],
                'quota' => ['fixture' => 1],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new EmailValidatorClient(
            $fake,
            'fixture-token',
            static fn (int $delay) => null
        );

        $this->assertTrue($client->check('[email protected]')->isVerified());
        $this->assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryRateLimit(): void
    {
        $fake = new FakeTransport([new HttpResponse(429, '{}')]);
        $client = new EmailValidatorClient(
            $fake,
            'fixture-token',
            static fn (int $delay) => null
        );

        $result = $client->check('[email protected]');

        $this->assertSame(FailureKind::RateLimited, $result->failure);
        $this->assertSame(1, $fake->calls);
    }

    public function testFailureCreatesDeferredActiveDecision(): void
    {
        $fake = new FakeTransport([new HttpResponse(429, '{}')]);
        $result = (new EmailValidatorClient(
            $fake,
            'fixture-token',
            static fn (int $delay) => null
        ))->check('[email protected]');

        $policy = new RegistrationPolicy(
            'fixture-success',
            ['fixture-allow'],
            70,
            100,
            ['fixture-check' => true]
        );

        $decision = $policy->decide($result);

        $this->assertSame(AccountMode::Active, $decision->mode);
        $this->assertSame('deferred', $decision->validationState);
    }
}
vendor/bin/phpunit tests

Безбедност, набљудливост и распоредување

Задржете ја локалната валидација на синтаксата иако услугата исто така проверува синтакса. Таа евтино одбива погрешно форматиран влез пред да потроши оддалечена квота. CSRF-заштитата, подготвените изрази, password_hash(), единственото ограничување во базата на податоци, HTTPS, ограничувањата на големината на барањето и ограничувањето на стапката на ниво на апликација и понатаму се неопходни; валидацијата на е-пошта не е замена за безбедноста на регистрацијата.

Дневниците треба да содржат категории на исходи, латентност, повторувања и број на одложени валидации, но никогаш адреса на е-пошта, лозинка, токен или целосна URL-адреса на барањето. Поставете предупредување за трајни неуспеси на автентикацијата, зголемени истеци на време, ограничување на стапката, невалидни облици на одговори и раст на одложените сметки. Предупредувањето за квота треба да го промени оперативното однесување, а не да предизвика наплив од повторувања.

Извршете ја миграцијата на шемата при распоредувањето, направете само var да биде запишлив од PHP-процесот, оневозможете ги прикажаните продукциски грешки и овозможете запишување грешки во дневник. Внесете ги вредностите на околината преку механизмот за тајни на хостот. По ротацијата на токенот на услугата, извршете контролирано барање бидејќи претходно активниот токен е поништен.

За повеќе инстанци на апликацијата, заменете го SQLite со заедничката трансакциска база на податоци на тимот, задржувајќи го единственото ограничување за е-пошта и истата доменска политика. Закажан процес може повторно да ги разгледува одложените записи со ограничени серии и одложување, но не треба тивко да оневозможи постоечка сметка само затоа што подоцнежна инфраструктурна проверка исто така не успеала.

Чести начини на неуспех

  • 401 или 403: проверете го токенот ограничен на услугата и дали неодамна бил повторно генериран. Не повторувајте наслепо.
  • 429: третирајте ја валидацијата како одложена, проверете ги квотата и сообраќајот и почекајте пред повторната проверка.
  • Истек на време или 502/503/504: дозволете го единственото ограничено повторување, потоа зачувајте ја достапноста на регистрацијата.
  • Невалиден JSON или полиња што недостигаат: класифицирајте го одговорот како невалиден наместо да претпоставувате стандардни вредности.
  • Секоја сметка останува во очекување: споредете ги конфигурираните статус, препорака, интервал на резултат и потребни проверки со официјалната документација и со вистински одговор.
  • Дупликат поднесувања: потпрете се на ограничувањето за единственост во базата на податоци, а не на проверка пред вметнување подложна на состојба на трка.

Конечна контролна листа за потврда

  • Точната GET-крајна точка ги прима само документираните параметри token и email.
  • Токенот на услугата постои само во конфигурација поддржана од променливи на околината.
  • Литералите на политиката и опсегот на резултатот се совпаѓаат со документацијата на активираната услуга.
  • Валидно оддалечено одобрување создава активна, потврдена сметка.
  • Неубедлив успешен одговор создава сметка во очекување.
  • Истек на време, 429, грешка при автентикација или погрешно форматиран одговор создава активна сметка означена како одложена.
  • Повторувањата се ограничени и ги исклучуваат неуспесите на автентикација, валидација и ограничување на стапката.
  • Тестовите поминуваат без пристап до мрежа или вистински ингеренции.
  • Дневниците ги изложуваат оперативните исходи без ингеренции или лични податоци.

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

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

Mihajlo

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