Tutorials

Graceful Email Validation: Keep Registrations Flowing Amidst API Outages

Graceful Email Validation: Keep Registrations Flowing Amidst API Outages

A registration form has two jobs that can pull in opposite directions: keep poor-quality addresses out and let legitimate people in. An external email-validation service improves the first job, but treating that service as an infallible gatekeeper can quietly sabotage the second. A timeout, exhausted quota, or brief upstream incident should not turn into “Registration failed.”

This tutorial builds a Native PHP 8.3 registration endpoint with a deliberately graceful policy. Locally invalid email syntax is rejected immediately. The Email Validator examines syntax, domain, MX records, provider signals, and practical delivery risk. Its response is mapped into a typed application result. However, temporary API failures never reject the applicant: the account is created as unverified and proceeds through normal email confirmation.

An external validator should strengthen registration decisions, not become a single point of refusal.

Get access before writing integration code

First, register for an account, or use the sign-in page if you already have one.

  1. Open the Email Validator service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official Email Validator documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store the token in project environment configuration, never in PHP source control.

This service requires a token. Authentication uses the token={serviceToken} query parameter. Regenerating the service token revokes the previously active token, so token rotation must update the deployed environment before old credentials are expected to work.

Confirm the endpoint with a minimal request

The exact call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Supply both email and token as query parameters:

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

Run that only in a trusted shell. Because the credential is in the query string, command history, debugging output, access logs, and proxy logs deserve particular attention. The application below never logs the request URL.

Create a local .env file and exclude it from version control:

APP_ENV=local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
DB_DSN=sqlite:/absolute/path/to/project/var/app.sqlite

For local development, export the file into the process environment before starting PHP. Production should inject the same variables through the deployment platform rather than copying .env into an image:

set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public

Architecture: fail open, but verify before trust

“Fail open” does not mean treating every address as trustworthy. It means allowing registration to continue while keeping the account unverified. The user still has to complete the application’s email-confirmation flow before receiving privileges that require a verified address.

The design has four boundaries:

  • The controller performs CSRF protection, required-field checks, and native syntax validation.
  • A dedicated cURL transport owns network mechanics and bounded timeouts.
  • The Email Validator client maps remote JSON into structured states such as checked, quota_limited, authentication_failure, and unavailable.
  • The registration policy rejects only deterministic local errors. Every remote result, including a successful risk assessment, is retained as context for verification and observability.

The supplied contract identifies status, score, recommendation, checks, and quota, but does not establish their value sets or a score scale here. The adapter therefore validates their types without inventing thresholds or recommendation meanings. If the official documentation defines a blocking rule you want to adopt, encode its exact documented values in the policy and keep outage states non-blocking.

Project structure and dependencies

graceful-registration/
├── composer.json
├── .env
├── public/
│   └── register.php
├── src/
│   ├── EmailValidator.php
│   └── RegistrationPolicy.php
├── tests/
│   └── EmailValidatorClientTest.php
└── var/

Use Composer only for autoloading and PHPUnit. Runtime networking remains native cURL:

{
  "name": "example/graceful-registration",
  "type": "project",
  "require": {
    "php": ">=8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.5"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
mkdir -p var
chmod 700 var

Build a defensive API boundary

The transport is replaceable, making tests deterministic. The client retries only a transport exception, HTTP 408, or a server-side 5xx response, and performs at most two attempts. Authentication failures, malformed requests, and quota responses are not blindly retried. Connection and total response times are bounded.

<?php
// src/EmailValidator.php
declare(strict_types=1);

namespace App;

use JsonException;
use RuntimeException;

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

interface HttpTransport
{
    public function get(
        string $url,
        int $connectTimeoutMs,
        int $timeoutMs
    ): HttpResponse;
}

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

        if ($handle === false) {
            throw new RuntimeException('Unable to initialize cURL');
        }

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Email validator transport failed: ' . $message);
        }

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

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

final readonly class EmailValidationOutcome
{
    public function __construct(
        public string $state,
        public ?string $status = null,
        public ?float $score = null,
        public ?string $recommendation = null,
        public array $checks = [],
        public array $quota = [],
    ) {}
}

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

    public function __construct(
        private HttpTransport $transport,
        private string $token,
        private ?\Closure $sleep = null,
    ) {
        if ($token === '' || $token === 'YOUR_SERVICE_TOKEN') {
            throw new RuntimeException('EMAIL_VALIDATOR_TOKEN is not configured');
        }
    }

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

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = $this->transport->get($url, 2_000, 4_000);
            } catch (RuntimeException) {
                if ($attempt === 1) {
                    $this->backoff();
                    continue;
                }

                return new EmailValidationOutcome('unavailable');
            }

            $retryable = $response->statusCode === 408
                || $response->statusCode >= 500;

            if ($retryable && $attempt === 1) {
                $this->backoff();
                continue;
            }

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

        return new EmailValidationOutcome('unavailable');
    }

    private function map(HttpResponse $response): EmailValidationOutcome
    {
        if (in_array($response->statusCode, [401, 403], true)) {
            return new EmailValidationOutcome('authentication_failure');
        }

        if ($response->statusCode === 429) {
            return new EmailValidationOutcome('quota_limited');
        }

        if (in_array($response->statusCode, [400, 422], true)) {
            return new EmailValidationOutcome('request_rejected');
        }

        if ($response->statusCode < 200 || $response->statusCode >= 300) {
            return new EmailValidationOutcome('unavailable');
        }

        try {
            $body = json_decode(
                $response->body,
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (JsonException) {
            return new EmailValidationOutcome('malformed_response');
        }

        if (!is_array($body)) {
            return new EmailValidationOutcome('malformed_response');
        }

        $data = isset($body['data']) && is_array($body['data'])
            ? $body['data']
            : $body;

        $status = $body['status'] ?? $data['status'] ?? null;
        $score = $data['score'] ?? null;
        $recommendation = $data['recommendation'] ?? null;
        $checks = $data['checks'] ?? null;
        $quota = $body['quota'] ?? $data['quota'] ?? null;

        if (
            !is_string($status)
            || $status === ''
            || !is_int($score) && !is_float($score)
            || !is_string($recommendation)
            || $recommendation === ''
            || !is_array($checks)
            || !is_array($quota)
        ) {
            return new EmailValidationOutcome('malformed_response');
        }

        return new EmailValidationOutcome(
            state: 'checked',
            status: $status,
            score: (float) $score,
            recommendation: $recommendation,
            checks: $checks,
            quota: $quota,
        );
    }

    private function backoff(): void
    {
        $delay = 150_000 + random_int(0, 50_000);
        $sleeper = $this->sleep
            ?? static fn (int $microseconds) => usleep($microseconds);

        $sleeper($delay);
    }
}

The adapter accepts required fields at either the response root or inside a data object, then rejects incomplete or incorrectly typed payloads as malformed. This is boundary hardening, not a claim that undocumented response shapes are guaranteed.

Turn validation into a registration decision

The policy keeps local certainty separate from remote evidence. All accepted accounts require email confirmation. A successful validator response records its complete contract; an outage records a degraded state without losing the registration.

<?php
// src/RegistrationPolicy.php
declare(strict_types=1);

namespace App;

final readonly class RegistrationDecision
{
    public function __construct(
        public bool $accept,
        public string $validatorState,
        public ?EmailValidationOutcome $outcome = null,
        public ?string $reason = null,
    ) {}
}

final readonly class RegistrationPolicy
{
    public function __construct(private EmailValidatorClient $validator) {}

    public function decide(string $email): RegistrationDecision
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            return new RegistrationDecision(
                accept: false,
                validatorState: 'local_rejection',
                reason: 'Enter a syntactically valid email address.'
            );
        }

        $outcome = $this->validator->check($email);

        return new RegistrationDecision(
            accept: true,
            validatorState: $outcome->state,
            outcome: $outcome
        );
    }
}

Connect the policy to the registration form

The controller creates the SQLite schema for this compact project, validates CSRF state, hashes the password, and stores the account as unverified. A unique constraint prevents duplicate addresses. In a larger application, migrations should own the schema and an existing mail component should send a single-use confirmation link after the transaction commits.

<?php
// public/register.php
declare(strict_types=1);

use App\CurlTransport;
use App\EmailValidatorClient;
use App\RegistrationPolicy;

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

session_start();

$token = getenv('EMAIL_VALIDATOR_TOKEN');
$dsn = getenv('DB_DSN');

if (!is_string($token) || !is_string($dsn) || $dsn === '') {
    http_response_code(500);
    exit('Application configuration is incomplete.');
}

$pdo = new PDO($dsn, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$pdo->exec(
    'CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL UNIQUE COLLATE NOCASE,
        password_hash TEXT NOT NULL,
        email_verified INTEGER NOT NULL DEFAULT 0,
        validator_state TEXT NOT NULL,
        validator_status TEXT NULL,
        validator_score REAL NULL,
        validator_recommendation TEXT NULL,
        created_at TEXT NOT NULL
    )'
);

$_SESSION['csrf'] ??= bin2hex(random_bytes(32));

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

$submittedCsrf = $_POST['csrf'] ?? '';
if (!is_string($submittedCsrf)
    || !hash_equals($_SESSION['csrf'], $submittedCsrf)
) {
    http_response_code(403);
    exit('Invalid form session.');
}

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

if (strlen($email) > 254 || strlen($password) < 12) {
    http_response_code(422);
    exit('Check the submitted email and password.');
}

$client = new EmailValidatorClient(new CurlTransport(), $token);
$decision = (new RegistrationPolicy($client))->decide($email);

if (!$decision->accept) {
    http_response_code(422);
    exit($decision->reason ?? 'Email cannot be accepted.');
}

$outcome = $decision->outcome;

try {
    $statement = $pdo->prepare(
        'INSERT INTO users (
            email, password_hash, email_verified, validator_state,
            validator_status, validator_score,
            validator_recommendation, created_at
        ) VALUES (
            :email, :password_hash, 0, :validator_state,
            :validator_status, :validator_score,
            :validator_recommendation, :created_at
        )'
    );

    $statement->execute([
        'email' => $email,
        'password_hash' => password_hash($password, PASSWORD_DEFAULT),
        'validator_state' => $decision->validatorState,
        'validator_status' => $outcome?->status,
        'validator_score' => $outcome?->score,
        'validator_recommendation' => $outcome?->recommendation,
        'created_at' => gmdate(DATE_ATOM),
    ]);
} catch (PDOException $exception) {
    if ((string) $exception->getCode() === '23000') {
        http_response_code(409);
        exit('An account for that email already exists.');
    }

    throw $exception;
}

error_log(json_encode([
    'event' => 'registration_created',
    'validator_state' => $decision->validatorState,
    'remote_status_present' => $outcome?->status !== null,
    'score_present' => $outcome?->score !== null,
    'recommendation_present' => $outcome?->recommendation !== null,
    'checks_present' => ($outcome?->checks ?? []) !== [],
    'quota_present' => ($outcome?->quota ?? []) !== [],
], JSON_THROW_ON_ERROR));

unset($_SESSION['csrf']);
http_response_code(201);
echo 'Account created. Check your inbox to verify your email address.';

The log uses every remote contract area for operational classification without recording the email, token, raw checks, or quota contents. Persisting only the fields needed by product policy also reduces unnecessary data retention.

Test retries and degraded registration deterministically

A fake transport lets tests drive success and failure paths without network access. The fixture values below are deliberately opaque; the test verifies boundary mapping rather than claiming service-specific semantics.

<?php
// tests/EmailValidatorClientTest.php
declare(strict_types=1);

use App\EmailValidatorClient;
use App\HttpResponse;
use App\HttpTransport;
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $items) {}

    public function get(
        string $url,
        int $connectTimeoutMs,
        int $timeoutMs
    ): HttpResponse {
        $this->calls++;
        $item = array_shift($this->items);

        if ($item instanceof Throwable) {
            throw $item;
        }

        return $item;
    }
}

final class EmailValidatorClientTest extends TestCase
{
    public function testItRetriesOneServerFailureAndMapsTheResponse(): void
    {
        $transport = new QueueTransport([
            new HttpResponse(503, ''),
            new HttpResponse(200, json_encode([
                'status' => 'fixture-status',
                'score' => 12.5,
                'recommendation' => 'fixture-recommendation',
                'checks' => ['fixture-check' => true],
                'quota' => ['fixture-quota' => 7],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new EmailValidatorClient(
            $transport,
            'test-token',
            static fn (int $microseconds) => null
        );

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

        self::assertSame('checked', $outcome->state);
        self::assertSame(12.5, $outcome->score);
        self::assertSame(2, $transport->calls);
    }

    public function testAnOutageBecomesUnavailableAfterTwoAttempts(): void
    {
        $transport = new QueueTransport([
            new RuntimeException('timeout'),
            new RuntimeException('timeout'),
        ]);

        $client = new EmailValidatorClient(
            $transport,
            'test-token',
            static fn (int $microseconds) => null
        );

        self::assertSame(
            'unavailable',
            $client->check('[email protected]')->state
        );
        self::assertSame(2, $transport->calls);
    }

    public function testQuotaResponseIsNotRetried(): void
    {
        $transport = new QueueTransport([new HttpResponse(429, '')]);
        $client = new EmailValidatorClient($transport, 'test-token');

        self::assertSame(
            'quota_limited',
            $client->check('[email protected]')->state
        );
        self::assertSame(1, $transport->calls);
    }
}
vendor/bin/phpunit --testdox tests

Add controller-level tests for invalid CSRF tokens, malformed addresses, duplicate accounts, short passwords, authentication failure, malformed JSON, and successful registration during a simulated timeout. The crucial assertion is that an unavailable validator still produces an unverified user record.

Security, observability, and deployment

Treat the service token as a secret even though it travels in a query parameter. Keep it out of repositories, exception messages, application-performance traces, reverse-proxy query logs, and copied request URLs. Restrict access to deployment configuration and rotate the token deliberately, remembering that regeneration revokes the old token.

Rate-limit the registration route by IP and broader abuse signals, but avoid relying on IP alone. Keep CSRF protection, password hashing, confirmation-token expiration, duplicate handling, and generic account responses in place. The validator complements these controls; it does not replace them.

Monitor counts by validator_state, retry frequency, latency, malformed responses, and the presence of quota data. Alert on sustained authentication_failure, because that usually requires operator action. A rise in quota_limited calls suggests plan or traffic review. A short burst of unavailable states should degrade registration, not page users with an upstream error.

During deployment, verify that cURL and PDO SQLite are enabled, the database directory is writable only by the application account, Composer’s optimized autoloader is built, and the environment contains the active token. Deploy code that understands both old and new operational states before rotating credentials. For multiple application instances, replace SQLite with the application’s shared database while retaining the same policy and unique constraint.

Common failure modes

  • Every call returns authentication failure: confirm the service-scoped token, plan activation, environment injection, and whether somebody regenerated the token.
  • Quota responses trigger slow forms: do not retry HTTP 429 within the request. Record the state and continue with email confirmation.
  • Timeouts consume the PHP worker pool: retain short connection and total timeouts, and resist multiplying retries.
  • Successful responses become malformed: inspect a safely redacted response against the official documentation. Do not silently coerce missing fields.
  • Users are still locked out during incidents: check the controller policy. Only deterministic local validation should reject in this design.
  • Tokens appear in logs: disable query-string logging for this outbound destination and remove URL values from exceptions and tracing attributes.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The service token comes from the documentation page’s Service token panel.
  • EMAIL_VALIDATOR_TOKEN is injected through environment configuration.
  • The request uses GET, the exact endpoint, and the email and token query parameters.
  • Connection and response timeouts are bounded.
  • Only transient transport, 408, and 5xx failures receive one retry.
  • Authentication, request-validation, and quota failures are not retried.
  • status, score, recommendation, checks, and quota are type-checked at the boundary.
  • Temporary API failures still create an unverified account.
  • Logs contain structured states but no email address, raw URL, or token.
  • Automated tests cover success, retry exhaustion, and quota handling.
  • The normal email-confirmation flow remains mandatory before trust is granted.

The most resilient registration system is not the one that pretends dependencies never fail. It is the one that distinguishes certainty from evidence: reject what the application can prove is malformed, enrich decisions when the validator responds, and preserve a safe path forward when it does not. That keeps protection strong without making availability somebody else’s promise.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.