Native PHP: Robust Registration Forms with AI Email Validation Fallbacks
A registration form has two jobs that occasionally pull in opposite directions: keep risky addresses out, and let legitimate people in. A remote email-validation service improves the first job, but a network timeout, exhausted quota, or temporary upstream failure must not break the second.
This tutorial builds a Native PHP 8.3 registration flow around that principle. Successful validation can activate an account immediately. An inconclusive validation result can hold it for review. A temporary API failure creates the account with deferred validation, preserving availability without pretending the check succeeded.
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 available Free, Plus, or Pro plan and complete its activation.
- Open the official Email Validator documentation.
- Find the Service token panel and copy the service-scoped token.
This service requires a token. Regenerating it revokes the previously active token, so treat rotation as a deployment change: update the application environment promptly and verify the new credential before removing any operational alert.
Confirm the API contract before building the feature
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address goes in email.
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode '[email protected]'
The response supplies status, score, recommendation, checks, and quota. The service considers syntax, domain information, MX records, provider signals, and practical delivery risk.
Do not guess the meaning of undocumented recommendation literals, score ranges, or check names. Inspect the activated plan’s documentation and test response, then configure the application policy with the documented values. That separation keeps service vocabulary out of domain code.
Store the credential and policy in an environment file outside the public directory:
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}
Replace every placeholder before deployment. Never commit this file or place the token in source code, logs, test fixtures, URLs shown to users, or screenshots.
Project shape and decision model
You need PHP 8.3 or newer with cURL, PDO, JSON, and OpenSSL support, plus Composer. This example uses SQLite for a modest single-host application and PHPUnit 11 for tests.
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
Add PSR-4 autoloading to composer.json and run composer dump-autoload:
{
"require": {
"php": "^8.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
The policy has three outcomes:
- Active and verified: every configured API condition is satisfied.
- Pending review: the API answered correctly, but its evidence did not meet the activation policy.
- Active with deferred validation: transport, authentication, rate-limit, or response-shape failure prevented a trustworthy decision. The user is not rejected because infrastructure failed.
Isolate HTTP transport from API semantics
A narrow transport interface makes the client deterministic in tests. Native cURL retains TLS verification, uses bounded timeouts, and never logs the URL because it contains both the token and email address.
<?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);
}
}
Map the remote response into a domain result
The client distinguishes failures instead of returning a vague boolean. It retries a transport failure or HTTP 502, 503, or 504 once, with a short randomized backoff. It does not retry authentication failures, malformed requests, or HTTP 429: immediate retries cannot repair bad credentials and can worsen quota pressure.
<?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'],
));
}
}
Turn evidence into an account decision
The policy uses all five response areas. It matches the documented status and recommendation, applies the configured score interval, compares selected checks with their expected values, and requires quota metadata to be present. Unknown fields remain harmless, while missing or structurally invalid fields were already rejected at the boundary.
<?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;
}
}
Persist registration safely
Create the database once:
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
);
The POST handler should perform CSRF verification, inexpensive local validation, remote validation, and a parameterized insert. The same neutral response for new and duplicate addresses reduces account enumeration.
<?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.';
Test retries and the fail-open boundary
The fake transport contains no network behavior and no real credential. Its literals are application-controlled fixtures, not claims about the service’s production vocabulary.
<?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
Security, observability, and deployment
Keep local syntax validation even though the service checks syntax too. It cheaply rejects malformed input before consuming remote quota. CSRF protection, prepared statements, password_hash(), a unique database constraint, HTTPS, request-size limits, and application-level rate limiting remain necessary; email validation is not a replacement for registration security.
Logs should contain outcome categories, latency, retries, and deferred-validation counts, but never the email, password, token, or complete request URL. Alert on sustained authentication failures, increased timeouts, rate limiting, invalid response shapes, and growth in deferred accounts. A quota warning should change operational behavior, not trigger a burst of retries.
Run the schema migration during deployment, make only var writable by the PHP process, disable displayed production errors, and enable error logging. Inject environment values through the host’s secret mechanism. After rotating the service token, perform a controlled request because the previous active token has been revoked.
For multiple application instances, replace SQLite with the team’s shared transactional database while retaining the unique email constraint and the same domain policy. A scheduled process can revisit deferred records with bounded batches and backoff, but it should not silently disable an existing account solely because a later infrastructure check also failed.
Common failure modes
- 401 or 403: verify the service-scoped token and whether it was recently regenerated. Do not retry blindly.
- 429: treat validation as deferred, inspect quota and traffic, and wait before rechecking.
- Timeout or 502/503/504: allow the single bounded retry, then preserve registration availability.
- Invalid JSON or missing fields: classify the response as invalid instead of guessing defaults.
- Every account remains pending: compare the configured status, recommendation, score interval, and required checks with the official documentation and an actual response.
- Duplicate submissions: rely on the database uniqueness constraint, not a race-prone pre-insert lookup.
Final verification checklist
- The exact GET endpoint receives only the documented
tokenandemailparameters. - The service token exists only in environment-backed configuration.
- The policy literals and score range match the activated service documentation.
- A valid remote approval creates an active, verified account.
- An inconclusive successful response creates a pending account.
- A timeout, 429, authentication error, or malformed response creates an active account marked deferred.
- Retries are bounded and exclude authentication, validation, and rate-limit failures.
- Tests pass without network access or real credentials.
- Logs expose operational outcomes without credentials or personal data.
The durable lesson is larger than email validation: an external risk signal should improve a registration decision without becoming an accidental outage switch. By separating transport, response mapping, policy, and persistence, this form remains strict when evidence is trustworthy and humane when infrastructure is not.