Vodiči

Laravel: Secure Registrations with AI Email Validation Fallbacks

Laravel: Sigurne registracije s AI rezervnim opcijama za validaciju e-pošte

Email validation becomes dangerous when it is treated as a simple yes-or-no network call. Reject every suspicious address and you reduce low-quality registrations. Reject every request when the validation service is unavailable, however, and a brief timeout can lock legitimate customers out of your application.

This tutorial builds a production-oriented Laravel registration flow that checks syntax, domain information, MX records, provider signals, and practical delivery risk through the Email Validator API. Clear negative recommendations are rejected, successful assessments are recorded, and temporary failures produce a deferred result instead of a broken registration page.

Get access and copy the service token

Start by creating an account at https://ai.mihajlo.mk/register. If you already have one, sign in at https://ai.mihajlo.mk/login.

  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 environment-backed configuration, never in PHP source or version control.

This service requires a token. Regenerating it revokes the previously active token, so token rotation must include updating every deployed environment that uses the old value.

The exact API operation is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address is supplied through email. Before writing Laravel code, make one minimal request from a secure terminal:

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

A successful response supplies status, score, recommendation, checks, and quota. Do not print production responses in shared logs because they may contain address-related data.

Add the credential to the Laravel environment before building the feature:

# .env
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_CONNECT_TIMEOUT=2
EMAIL_VALIDATOR_TIMEOUT=5
EMAIL_VALIDATOR_SUCCESS_STATUS=success
EMAIL_VALIDATOR_BLOCK_RECOMMENDATIONS=reject

The status and recommendation settings are deliberately application configuration. Confirm their exact values against the official documentation and the responses available to your activated plan. The integration does not assume undocumented nested fields inside checks or quota.

Architecture and project shape

This implementation targets PHP 8.3 or later and a current Laravel application with its normal users table, authentication scaffolding, database, and test environment. It uses Laravel's built-in HTTP client, so no additional HTTP package is required.

The relevant files are:

  • config/services.php for environment-backed settings
  • app/Domain/EmailValidation/EmailAssessment.php for the domain result
  • app/Services/EmailValidator.php for HTTP transport, response mapping, and policy
  • app/Http/Controllers/Auth/RegisteredUserController.php for registration
  • database/migrations/..._add_email_validation_state_to_users_table.php for deferred-state persistence
  • tests/Feature/RegistrationEmailValidationTest.php for deterministic external-call tests

The controller never reasons about raw JSON or HTTP status codes. It receives one of three outcomes: allow, reject, or defer. Only reject blocks the form. Network failures, quota responses, malformed payloads, and service errors become defer, allowing registration while preserving an operational signal for later review.

Configure the API boundary

Add a dedicated entry to config/services.php:

'email_validator' => [
    'url' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
    'token' => env('EMAIL_VALIDATOR_TOKEN'),
    'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 2),
    'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 5),
    'success_status' => env('EMAIL_VALIDATOR_SUCCESS_STATUS', 'success'),
    'block_recommendations' => array_values(array_filter(array_map(
        'trim',
        explode(',', env('EMAIL_VALIDATOR_BLOCK_RECOMMENDATIONS', 'reject'))
    ))),
],

Environment access stays in configuration files, which means config:cache remains safe. Application classes read config(), not env().

Represent the domain decision

<?php

namespace App\Domain\EmailValidation;

enum EmailOutcome: string
{
    case Allow = 'allow';
    case Reject = 'reject';
    case Defer = 'defer';
}

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

    public static function defer(string $reason): self
    {
        return new self(EmailOutcome::Defer, $reason);
    }
}

The reason is a stable internal label, not text shown directly to a user. The complete remote fields remain available to domain code, but the controller needs only the normalized outcome.

Build the Laravel HTTP service

<?php

namespace App\Services;

use App\Domain\EmailValidation\EmailAssessment;
use App\Domain\EmailValidation\EmailOutcome;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

final class EmailValidator
{
    public function check(string $email): EmailAssessment
    {
        $token = config('services.email_validator.token');

        if (! is_string($token) || $token === '') {
            Log::error('Email validator token is not configured');

            return EmailAssessment::defer('configuration_error');
        }

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(config('services.email_validator.connect_timeout'))
                    ->timeout(config('services.email_validator.timeout'))
                    ->get(config('services.email_validator.url'), [
                        'token' => $token,
                        'email' => $email,
                    ]);
            } catch (ConnectionException $exception) {
                Log::warning('Email validator connection failure', [
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt === 1) {
                    usleep(200_000);
                    continue;
                }

                return EmailAssessment::defer('connection_failure');
            }

            if ($attempt === 1 && in_array($response->status(), [502, 503, 504], true)) {
                usleep(200_000);
                continue;
            }

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

        return EmailAssessment::defer('connection_failure');
    }

    private function mapResponse(Response $response): EmailAssessment
    {
        if ($response->status() === 429) {
            Log::warning('Email validator quota or rate limit reached');

            return EmailAssessment::defer('quota_or_rate_limit');
        }

        if (in_array($response->status(), [401, 403], true)) {
            Log::error('Email validator authentication failed', [
                'http_status' => $response->status(),
            ]);

            return EmailAssessment::defer('authentication_failure');
        }

        if (! $response->successful()) {
            Log::warning('Email validator returned an HTTP failure', [
                'http_status' => $response->status(),
            ]);

            return EmailAssessment::defer('remote_http_failure');
        }

        $body = $response->json();
        $required = ['status', 'score', 'recommendation', 'checks', 'quota'];

        if (! is_array($body)
            || array_diff($required, array_keys($body)) !== []
            || ! is_string($body['status'])
            || ! is_numeric($body['score'])
            || ! is_string($body['recommendation'])
            || ! is_array($body['checks'])
            || ! is_array($body['quota'])
            || $body['checks'] === []
            || $body['quota'] === []) {
            Log::warning('Email validator returned an invalid contract');

            return EmailAssessment::defer('invalid_response');
        }

        $status = trim($body['status']);
        $score = (float) $body['score'];
        $recommendation = strtolower(trim($body['recommendation']));
        $successStatus = strtolower((string) config(
            'services.email_validator.success_status'
        ));

        if (strtolower($status) !== $successStatus || ! is_finite($score)) {
            return EmailAssessment::defer('remote_status_not_usable');
        }

        $blocked = array_map(
            static fn (string $value): string => strtolower($value),
            config('services.email_validator.block_recommendations', [])
        );

        $outcome = in_array($recommendation, $blocked, true)
            ? EmailOutcome::Reject
            : EmailOutcome::Allow;

        return new EmailAssessment(
            outcome: $outcome,
            reason: $outcome === EmailOutcome::Reject
                ? 'remote_recommendation_blocked'
                : 'remote_recommendation_allowed',
            status: $status,
            score: $score,
            recommendation: $recommendation,
            checks: $body['checks'],
            quota: $body['quota'],
        );
    }
}

The retry policy is intentionally narrow. One retry covers transient connection failures and gateway-style 502, 503, or 504 responses. Authentication failures, validation failures, and quota responses are not blindly retried. Bounded connection and total-response timeouts prevent a slow dependency from consuming all available PHP workers.

The mapper verifies every contracted field. status establishes whether the response is usable, score must be finite, recommendation drives the configured allow-or-reject policy, and non-empty checks and quota confirm that the expected assessment and usage context arrived. No undocumented check name or quota subfield is assumed.

Persist deferred validation without blocking registration

Add a compact state column to the users table:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('email_validation_state', 16)
                ->default('pending');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->dropColumn('email_validation_state');
        });
    }
};

Run php artisan migrate. The state supports ordinary operational follow-up without introducing a queue solely for architectural decoration. A later scheduled reconciliation process can revisit deferred accounts if the product requires it.

Connect validation to registration

In the registration controller, perform Laravel's local validation first. This avoids spending remote quota on malformed addresses, weak passwords, or duplicates.

<?php

namespace App\Http\Controllers\Auth;

use App\Domain\EmailValidation\EmailOutcome;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\EmailValidator;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;

final class RegisteredUserController extends Controller
{
    public function store(
        Request $request,
        EmailValidator $emailValidator
    ): RedirectResponse {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ]);

        $assessment = $emailValidator->check($validated['email']);

        if ($assessment->outcome === EmailOutcome::Reject) {
            return back()
                ->withInput($request->except('password', 'password_confirmation'))
                ->withErrors([
                    'email' => 'Please use an email address that can receive account messages.',
                ]);
        }

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
            'email_validation_state' => $assessment->outcome->value,
        ]);

        event(new Registered($user));
        Auth::login($user);

        return redirect()->route('dashboard');
    }
}

Keep the route in routes/web.php, where Laravel supplies session and CSRF protection:

use App\Http\Controllers\Auth\RegisteredUserController;
use Illuminate\Support\Facades\Route;

Route::post('/register', [RegisteredUserController::class, 'store'])
    ->middleware('throttle:6,1')
    ->name('register');

The throttle reduces automated abuse before it can consume validation quota. Laravel's email rule remains necessary because an external risk assessment should complement, not replace, deterministic local input validation.

Test decisions and failure paths

Http::fake() keeps tests deterministic and proves that no real credentials or network access are required. These feature tests cover a hard recommendation and the essential fail-open behavior.

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class RegistrationEmailValidationTest extends TestCase
{
    use RefreshDatabase;

    public function test_blocked_recommendation_rejects_registration(): void
    {
        Http::fake([
            '*' => Http::response([
                'status' => 'success',
                'score' => 12,
                'recommendation' => 'reject',
                'checks' => ['available' => true],
                'quota' => ['reported' => true],
            ]),
        ]);

        $this->post('/register', $this->payload())
            ->assertSessionHasErrors('email');

        $this->assertDatabaseMissing('users', [
            'email' => '[email protected]',
        ]);
    }

    public function test_temporary_failures_defer_but_allow_registration(): void
    {
        Http::fakeSequence()
            ->push([], 503)
            ->push([], 503);

        $this->post('/register', $this->payload())
            ->assertRedirect(route('dashboard'));

        $this->assertDatabaseHas('users', [
            'email' => '[email protected]',
            'email_validation_state' => 'defer',
        ]);

        Http::assertSentCount(2);
    }

    private function payload(): array
    {
        return [
            'name' => 'Example Person',
            'email' => '[email protected]',
            'password' => 'A-long-test-password',
            'password_confirmation' => 'A-long-test-password',
        ];
    }
}

Add tests for an allowed recommendation, malformed JSON, 401, 429, and a thrown ConnectionException. Also use Http::assertSent() to verify the endpoint and query keys, while deliberately avoiding assertions that expose a real token.

Security, observability, and deployment

Never log the query string: it contains both the token and the user's email address. The service logs above record only failure category, HTTP status, attempt number, and exception class. Send warning and error logs to your normal alerting destination, then monitor deferred registrations, authentication failures, rate limits, and the ratio of rejected to allowed decisions.

Deploy the migration before code that writes the new column. Configure the token independently in each environment, then cache configuration and run tests:

php artisan migrate --force
php artisan config:cache
php artisan test

During token rotation, update the environment secret, rebuild Laravel's configuration cache, and verify a request before regenerating again. Remember that regeneration immediately revokes the previous active token.

Common failures

  • Every request is deferred: check the deployed token, cached configuration, success-status configuration, outbound HTTPS access, and application logs.
  • Authentication fails after rotation: one deployment still has the revoked token or stale configuration cache.
  • 429 responses appear: inspect plan usage and registration abuse. Do not multiply the problem with immediate retries.
  • Valid responses are marked malformed: compare the actual response with the official documentation and adjust the boundary only for documented shapes.
  • Tests contact production: ensure every relevant test installs Http::fake(); never place a live token in test fixtures.

Final verification checklist

  • The service plan is active and the service-scoped token comes from the documentation page.
  • The token exists only in environment-backed secret storage.
  • The client calls the exact GET endpoint with token and email.
  • Connection and response timeouts are bounded.
  • Only transient connection and gateway failures receive one delayed retry.
  • All five contracted response fields are validated at the API boundary.
  • Explicitly blocked recommendations stop registration.
  • Timeouts, quota limits, malformed responses, and API outages defer validation without rejecting the user.
  • Logs exclude tokens, email addresses, and raw response bodies.
  • Feature tests prove both rejection and fail-open registration paths.

The durable lesson is not merely to call an email-validation API. It is to decide what the dependency is allowed to control. A confident risk signal may protect the registration form, but a temporary infrastructure problem should not become a verdict on a real person. By making defer a first-class domain outcome, the integration remains useful when the service is healthy and humane when it is not.

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.