Izvorni PHP 8.3: Elegantni neuspjesi validacije e-pošte pri registraciji korisnika
Email validation becomes a production problem at precisely the wrong moment: after a registration form has started receiving real traffic. Disposable addresses, malformed domains, and mailboxes with poor delivery signals create support work and damage sender reputation. Yet making an external API a hard dependency can be worse. If that service briefly times out, legitimate customers should not lose their registrations.
This tutorial builds a Native PHP 8.3 registration flow that consults an email-validation service synchronously, rejects only sufficiently clear risk results, and creates a verification-pending account when temporary failures prevent an assessment. The boundary is deliberately conservative: uncertain or malformed responses never masquerade as trustworthy validation.
Get access before writing integration code
Start by registering an account, or use the sign-in page if you already have one.
- 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.
- Store it in environment-backed configuration, never in PHP source or a committed fixture.
This service requires a token. Authentication uses the token={serviceToken} query parameter. Regenerating the token revokes the previously active token, so rotation must update every deployed instance before old credentials are assumed to work.
Confirm the exact endpoint
The integration makes an HTTP GET request to https://ai.mihajlo.mk/api/email-validator/v1/check-email. It sends token, score, recommendation, checks, and quota data.
Make one minimal test request from a trusted development machine. Shell history may retain the command, so use an environment variable rather than pasting the token directly:
export EMAIL_VALIDATOR_TOKEN='YOUR_SERVICE_TOKEN'
curl --silent --show-error --get \
--data-urlencode "token=${EMAIL_VALIDATOR_TOKEN}" \
--data-urlencode "[email protected]" \
https://ai.mihajlo.mk/api/email-validator/v1/check-email
For local development, create an untracked .env file:
APP_ENV=development
APP_KEY=replace-with-a-long-random-secret
DATABASE_DSN=sqlite:/absolute/path/to/var/registration.sqlite
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_MIN_SCORE=45
EMAIL_VALIDATOR_DENY_RECOMMENDATIONS=reject
The threshold and deny list are application policy, not claims about undocumented service enums. Compare them with the current official documentation and responses for your activated plan before enabling rejection. An empty deny list makes remote findings advisory while email confirmation still protects the account.
Architecture: strict boundary, graceful business flow
The project has four small layers:
- The controller performs CSRF protection, local syntax validation, and persistence.
- A cURL transport owns connection limits, response timeouts, and bounded retries.
- The API client validates external JSON and maps it into a typed assessment.
- A policy converts that assessment into
accept,reject, ordefer.
A successful low-risk assessment creates an account awaiting email confirmation. A clear deny recommendation, corroborated by a low score or failed check, rejects the address. Network failures, HTTP 429 responses, and exhausted retries create the same pending account but mark validation as deferred. Authentication and other non-retryable client errors stop registration with a service-unavailable response because silently accepting traffic during a permanent configuration failure would create an unlimited fail-open path.
Use this structure:
registration/
├── composer.json
├── .env
├── public/index.php
├── src/EmailAssessment.php
├── src/EmailValidatorClient.php
├── src/RegistrationPolicy.php
├── src/Transport.php
└── tests/EmailValidationTest.php
Install PHPUnit as the only development dependency. Native cURL handles production HTTP:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
composer dump-autoload
mkdir -p var
php -S 127.0.0.1:8080 -t public
Build the defensive API boundary
The transport retries only network failures and HTTP 502, 503, or 504 responses. Because this is an idempotent GET, two retries with short backoff are reasonable. HTTP 400, 401, and 403 are not retried. HTTP 429 is returned immediately as a temporary failure so one busy registration request does not add more pressure.
<?php
// src/Transport.php
namespace App;
final class TemporaryFailure extends \RuntimeException {}
final class PermanentFailure extends \RuntimeException {}
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
{
$target = $url . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$delays = [0, 150_000, 400_000];
foreach ($delays as $attempt => $delay) {
if ($delay > 0) {
usleep($delay + random_int(0, 50_000));
}
$handle = curl_init($target);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 2_000,
CURLOPT_TIMEOUT_MS => 4_000,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($handle);
$error = curl_error($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if ($body === false) {
if ($attempt < 2) {
continue;
}
throw new TemporaryFailure('Email validation network failure');
}
if (in_array($status, [502, 503, 504], true)) {
if ($attempt < 2) {
continue;
}
throw new TemporaryFailure('Email validation upstream failure');
}
if ($status === 429) {
throw new TemporaryFailure('Email validation rate or quota limit');
}
if ($status >= 400) {
throw new PermanentFailure('Email validation HTTP ' . $status);
}
return new HttpResponse($status, $body);
}
throw new TemporaryFailure('Email validation unavailable');
}
}
The mapper accepts no undocumented nesting and does not assume that arbitrary JSON is safe. It preserves all five promised response areas so policy and audit code can use them without passing an untrusted array throughout the application.
<?php
// src/EmailAssessment.php
namespace App;
final readonly class EmailAssessment
{
public function __construct(
public string|bool|int|float $status,
public ?float $score,
public string $recommendation,
public array $checks,
public array $quota,
) {}
}
// src/EmailValidatorClient.php
namespace App;
final readonly class EmailValidatorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private Transport $transport,
private string $token,
) {
if ($token === '') {
throw new \InvalidArgumentException('Missing service token');
}
}
public function check(string $email): EmailAssessment
{
$response = $this->transport->get(self::ENDPOINT, [
'token' => $this->token,
'email' => $email,
]);
try {
$data = json_decode($response->body, true, 32, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
throw new TemporaryFailure('Invalid upstream JSON', 0, $error);
}
if (!is_array($data)
|| !array_key_exists('status', $data)
|| !array_key_exists('score', $data)
|| !is_string($data['recommendation'] ?? null)
|| !is_array($data['checks'] ?? null)
|| !is_array($data['quota'] ?? null)
|| (!is_string($data['status'])
&& !is_bool($data['status'])
&& !is_int($data['status'])
&& !is_float($data['status']))
|| ($data['score'] !== null && !is_numeric($data['score']))
) {
throw new TemporaryFailure('Unexpected upstream schema');
}
return new EmailAssessment(
$data['status'],
$data['score'] === null ? null : (float) $data['score'],
$data['recommendation'],
$data['checks'],
$data['quota'],
);
}
}
Turn vendor data into an application decision
A recommendation alone should not normally become an irreversible decision. This policy rejects only when an explicitly configured deny recommendation is accompanied by a low score or at least one failed boolean check. Everything else remains eligible for email confirmation.
<?php
// src/RegistrationPolicy.php
namespace App;
final readonly class Decision
{
public function __construct(
public string $action,
public string $reason,
public array $audit = [],
) {}
}
final readonly class RegistrationPolicy
{
public function __construct(
private float $minimumScore,
private array $denyRecommendations,
) {}
public function decide(EmailAssessment $assessment): Decision
{
$recommendation = strtolower(trim($assessment->recommendation));
$denied = in_array(
$recommendation,
array_map('strtolower', $this->denyRecommendations),
true
);
$failedCheck = $this->containsFalse($assessment->checks);
$lowScore = $assessment->score !== null
&& $assessment->score < $this->minimumScore;
$audit = [
'status' => $assessment->status,
'score' => $assessment->score,
'recommendation' => $assessment->recommendation,
'checks' => $assessment->checks,
'quota' => $assessment->quota,
];
if ($denied && ($lowScore || $failedCheck)) {
return new Decision('reject', 'remote_risk', $audit);
}
return new Decision('accept', 'assessment_complete', $audit);
}
private function containsFalse(array $values): bool
{
foreach ($values as $value) {
if ($value === false) {
return true;
}
if (is_array($value) && $this->containsFalse($value)) {
return true;
}
}
return false;
}
}
Protect the registration controller
Load .env only for local development. In production, inject the same names through the process manager, container platform, or secret store. The controller below assumes bootstrap code has populated getenv().
<?php
// public/index.php
declare(strict_types=1);
use App\CurlTransport;
use App\Decision;
use App\EmailValidatorClient;
use App\PermanentFailure;
use App\RegistrationPolicy;
use App\TemporaryFailure;
require dirname(__DIR__) . '/vendor/autoload.php';
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$token = htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8');
echo '<form method="post">'
. '<input type="hidden" name="csrf" value="' . $token . '">'
. '<input name="email" type="email" required>'
. '<input name="password" type="password" minlength="12" required>'
. '<button>Create account</button></form>';
exit;
}
if (!hash_equals($_SESSION['csrf'], (string) ($_POST['csrf'] ?? ''))) {
http_response_code(403);
exit('Invalid request');
}
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false
|| strlen($password) < 12
) {
http_response_code(422);
exit('Check the submitted fields');
}
$client = new EmailValidatorClient(
new CurlTransport(),
(string) getenv('EMAIL_VALIDATOR_TOKEN')
);
$deny = array_values(array_filter(array_map(
'trim',
explode(',', (string) getenv('EMAIL_VALIDATOR_DENY_RECOMMENDATIONS'))
)));
$policy = new RegistrationPolicy(
(float) (getenv('EMAIL_VALIDATOR_MIN_SCORE') ?: 45),
$deny
);
try {
$decision = $policy->decide($client->check($email));
} catch (TemporaryFailure $error) {
$decision = new Decision('defer', 'validator_temporarily_unavailable');
} catch (PermanentFailure $error) {
error_log(json_encode([
'event' => 'email_validator_configuration_failure',
'http_status' => 503,
], JSON_THROW_ON_ERROR));
http_response_code(503);
exit('Registration is temporarily unavailable');
}
if ($decision->action === 'reject') {
http_response_code(422);
exit('Please use another email address');
}
$pdo = new PDO((string) getenv('DATABASE_DSN'), options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_state TEXT NOT NULL,
validation_state TEXT NOT NULL,
created_at TEXT NOT NULL
)'
);
$statement = $pdo->prepare(
'INSERT INTO users
(email, password_hash, email_state, validation_state, created_at)
VALUES (:email, :hash, :email_state, :validation_state, :created_at)'
);
$statement->execute([
'email' => $email,
'hash' => password_hash($password, PASSWORD_DEFAULT),
'email_state' => 'confirmation_pending',
'validation_state' => $decision->action === 'defer'
? 'deferred'
: 'assessed',
'created_at' => gmdate(DATE_ATOM),
]);
http_response_code(201);
echo 'Account created. Check your email to continue.';
In a larger application, move schema creation into a migration and send confirmation mail through an outbox or queue after the database commit. A deferred validation record can be reassessed by a scheduled worker, but registration itself remains successful.
Test success, rejection, and failure deterministically
Tests should never call the live service. Injecting the transport makes response mapping and failure behavior repeatable without mocking cURL internals.
<?php
// tests/EmailValidationTest.php
use App\EmailValidatorClient;
use App\HttpResponse;
use App\RegistrationPolicy;
use App\TemporaryFailure;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public function __construct(private HttpResponse|\Throwable $result) {}
public function get(string $url, array $query): HttpResponse
{
if ($this->result instanceof \Throwable) {
throw $this->result;
}
return $this->result;
}
}
final class EmailValidationTest extends TestCase
{
public function testMapsAllContractFields(): void
{
$json = json_encode([
'status' => 'ok',
'score' => 91,
'recommendation' => 'allow',
'checks' => ['syntax' => true, 'mx' => true],
'quota' => ['remaining' => 20],
], JSON_THROW_ON_ERROR);
$client = new EmailValidatorClient(
new FakeTransport(new HttpResponse(200, $json)),
'test-token'
);
$result = $client->check('[email protected]');
self::assertSame(91.0, $result->score);
self::assertTrue($result->checks['mx']);
self::assertSame(20, $result->quota['remaining']);
}
public function testCorroboratedDenyIsRejected(): void
{
$assessment = new App\EmailAssessment(
'ok',
20.0,
'reject',
['mx' => false],
['remaining' => 10]
);
$decision = (new RegistrationPolicy(45, ['reject']))
->decide($assessment);
self::assertSame('reject', $decision->action);
}
public function testTemporaryFailureCanBecomeDeferredRegistration(): void
{
$client = new EmailValidatorClient(
new FakeTransport(new TemporaryFailure('timeout')),
'test-token'
);
$this->expectException(TemporaryFailure::class);
$client->check('[email protected]');
}
}
vendor/bin/phpunit --testdox tests
Security, observability, and deployment
Query-string authentication deserves special care because reverse proxies and tracing tools often record URLs. Disable query logging for this endpoint or redact the token parameter. Never log the raw email address either. If correlation is necessary, log an HMAC made with APP_KEY, the decision reason, latency, HTTP class, and retry count.
Track separate counters for assessed, rejected, deferred, rate-limited, malformed-response, and authentication-failure outcomes. Alert on sustained deferred traffic or any 401 or 403 response. Quota data may be included in restricted operational telemetry, but do not assume undocumented quota keys and do not expose it to registrants.
Production instances need the cURL and PDO extensions, outbound HTTPS access to the exact host, a writable database, and synchronized secret configuration. Deploy code first with both old and new credential handling where your platform permits it, update the secret, restart workers and PHP processes, verify a request, and only then regenerate the service token. Since regeneration revokes the previous token, reversing that order causes immediate authentication failures.
Common failures to anticipate
- Every request receives 401 or 403: confirm the service-scoped token, plan activation, and environment injection. Do not add retries.
- Registrations become deferred: inspect timeouts, DNS, TLS, HTTP 429 responses, upstream 5xx responses, and available quota.
- Valid users are rejected: verify the configured recommendation vocabulary and score threshold against current documentation. Start in advisory mode if uncertain.
- Duplicate accounts appear: retain the database unique constraint and translate its violation into an idempotent user-facing response.
- Latency grows under failure: keep both timeouts and retry count bounded; never perform an unbounded retry inside a web request.
Final verification checklist
- The exact GET endpoint receives URL-encoded
tokenandemailparameters. - The token exists only in environment-backed secret configuration.
status,score,recommendation,checks, andquotacross a validated application boundary.- Clear configured risk findings can reject an address.
- Timeouts, 429 responses, and retryable upstream failures create a confirmation-pending account marked for deferred validation.
- Authentication and configuration failures alert operators instead of silently opening the gate.
- CSRF protection, password hashing, unique email storage, and email confirmation remain in place.
- Tests use a deterministic fake transport and never consume live quota.
The resilient choice is not to pretend an external validator can never fail. It is to decide, in advance, which evidence justifies rejection and which uncertainty merely justifies another verification step. That distinction keeps an ordinary registration form useful during an outage while preserving the delivery-risk signals that made validation worthwhile in the first place.