Laravel: Елегантна валидација на е-пошта при регистрација за време на прекин на API-то
A registration form sits on a delicate boundary: it should stop obviously unusable addresses, but it should not lock out a real customer because a dependency had a bad afternoon. The right production design is therefore neither “always trust the API” nor “fail open without visibility.” It is a small, explicit decision system.
This tutorial builds that system in Laravel. The application validates basic input locally, asks the Email Validator for delivery-risk evidence, rejects only a clear and structurally trustworthy negative recommendation, and permits registration with a pending risk state when the service is unavailable or its response is uncertain.
Prerequisites
You need PHP 8.3 or later, Composer, a Laravel application with a configured database, and the normal users table. The examples use Laravel’s built-in HTTP client, authentication facade, validation, migrations, logging, events, and HTTP test fakes. No third-party integration package is required.
If starting fresh, create the application and prepare its database:
composer create-project laravel/laravel graceful-registration
cd graceful-registration
cp .env.example .env
php artisan key:generate
php artisan migrate
Get access before writing integration code
- Create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
- Open the Email Validator service page. Choose an 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 that token; it is not an unauthenticated API.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment operation: update the application secret promptly, rebuild cached configuration, verify a request, and only then consider the rotation complete.
Confirm the endpoint with a minimal request
The exact call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token={serviceToken} query parameter, while the address is supplied through the email query parameter.
For a one-off protected terminal test, place the token in a temporary shell variable rather than typing it directly into the URL:
export EMAIL_VALIDATOR_TEST_TOKEN="YOUR_SERVICE_TOKEN"
curl --get \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email" \
--data-urlencode "token=${EMAIL_VALIDATOR_TEST_TOKEN}" \
--data-urlencode "[email protected]"
The response contract provides status, score, recommendation, checks, and quota. The service considers syntax, domain and MX information, provider signals, and practical delivery risk. Our boundary will require all five fields before trusting a recommendation.
Store the credential in Laravel configuration
Add the secret to .env. Never commit a real value:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_URL=https://ai.mihajlo.mk
EMAIL_VALIDATOR_CONNECT_TIMEOUT=2
EMAIL_VALIDATOR_TIMEOUT=4
EMAIL_VALIDATOR_RETRY_DELAY_MS=200
Add this entry to the array returned by config/services.php:
'email_validator' => [
'url' => env('EMAIL_VALIDATOR_URL', 'https://ai.mihajlo.mk'),
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 2),
'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 4),
'retry_delay_ms' => (int) env('EMAIL_VALIDATOR_RETRY_DELAY_MS', 200),
],
Application code must read config(), not call env() directly. That keeps the integration compatible with Laravel’s configuration cache.
Choose a deliberately conservative architecture
Email validation is synchronous here because the decision can improve the registration response immediately. A queue job would finish too late to stop a clearly rejected address and would add operational machinery without improving the user experience.
The request path has three outcomes:
- Accept: the response is complete and its recommendation matches the application’s accepted vocabulary.
- Reject: the response is complete and contains a recognized negative recommendation.
- Defer: the API times out, reaches quota or rate limits, rejects authentication, returns malformed data, reports an unrecognized status, or supplies an unfamiliar recommendation.
Only the second outcome blocks registration. Deferred users are created with a pending risk state and can proceed through the application’s ordinary email-verification flow.
The example recognizes accept/valid and reject/invalid as local policy labels. Unknown labels defer safely. Check the official response examples for your activated service and adjust this small allowlist if the documented vocabulary differs. We intentionally do not invent a score cutoff or interpret undocumented keys inside checks or quota.
Map the API response into a domain decision
Create app/Domain/EmailAssessment.php. The mapper rejects partial or oddly typed payloads at the boundary. The score, checks, and quota metadata must be present before a recommendation is actionable; otherwise the result becomes deferred.
<?php
declare(strict_types=1);
namespace App\Domain;
use UnexpectedValueException;
enum EmailVerdict: string
{
case Accept = 'accept';
case Reject = 'reject';
case Defer = 'defer';
}
final readonly class EmailAssessment
{
public function __construct(
public EmailVerdict $verdict,
public string $status,
public ?float $score,
public string $recommendation,
public array $checks,
public array $quota,
public string $reason,
) {}
public static function fromPayload(array $payload): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (!array_key_exists($field, $payload)) {
throw new UnexpectedValueException("Missing field: {$field}");
}
}
if (!is_string($payload['status'])
|| !is_string($payload['recommendation'])
|| !(is_int($payload['score']) || is_float($payload['score']))
|| !is_array($payload['checks'])
|| !is_array($payload['quota'])) {
throw new UnexpectedValueException('Unexpected response types');
}
$status = strtolower(trim($payload['status']));
$recommendation = strtolower(trim($payload['recommendation']));
$score = (float) $payload['score'];
if (!is_finite($score)
|| $payload['checks'] === []
|| $payload['quota'] === []
|| $status !== 'success') {
return new self(
EmailVerdict::Defer,
$status,
$score,
$recommendation,
$payload['checks'],
$payload['quota'],
'untrusted_response'
);
}
$verdict = match ($recommendation) {
'accept', 'valid' => EmailVerdict::Accept,
'reject', 'invalid' => EmailVerdict::Reject,
default => EmailVerdict::Defer,
};
return new self(
$verdict,
$status,
$score,
$recommendation,
$payload['checks'],
$payload['quota'],
$verdict === EmailVerdict::Defer
? 'unknown_recommendation'
: 'recommendation'
);
}
public static function deferred(string $reason): self
{
return new self(
EmailVerdict::Defer,
'unavailable',
null,
'',
[],
[],
$reason
);
}
}
This is an anti-corruption layer: controllers never reason about arbitrary JSON. Notice that the score participates in response trust but is not compared with an undocumented threshold. The same restraint applies to nested check and quota fields.
Build a bounded, selective HTTP client
Create app/Services/EmailValidatorClient.php:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Domain\EmailAssessment;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use UnexpectedValueException;
final class EmailValidatorClient
{
public function check(string $email): EmailAssessment
{
$token = config('services.email_validator.token');
if (!is_string($token) || $token === '') {
Log::error('email_validator.configuration_missing');
return EmailAssessment::deferred('configuration_missing');
}
$response = null;
$delay = max(0, (int) config(
'services.email_validator.retry_delay_ms',
200
));
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::baseUrl(
(string) config('services.email_validator.url')
)
->acceptJson()
->connectTimeout((int) config(
'services.email_validator.connect_timeout',
2
))
->timeout((int) config(
'services.email_validator.timeout',
4
))
->get('/api/email-validator/v1/check-email', [
'token' => $token,
'email' => $email,
]);
} catch (ConnectionException) {
if ($attempt === 1) {
if ($delay > 0) {
usleep($delay * 1000);
}
continue;
}
Log::warning('email_validator.connection_failed');
return EmailAssessment::deferred('connection_failed');
}
if ($attempt === 1
&& in_array($response->status(), [502, 503, 504], true)) {
if ($delay > 0) {
usleep($delay * 1000);
}
continue;
}
break;
}
if ($response->status() === 429) {
Log::warning('email_validator.quota_or_rate_limited');
return EmailAssessment::deferred('quota_or_rate_limited');
}
if (in_array($response->status(), [401, 403], true)) {
Log::error('email_validator.authentication_failed');
return EmailAssessment::deferred('authentication_failed');
}
if (!$response->successful()) {
Log::warning('email_validator.http_failure', [
'http_status' => $response->status(),
]);
return EmailAssessment::deferred('http_failure');
}
$payload = $response->json();
if (!is_array($payload)) {
return EmailAssessment::deferred('malformed_response');
}
try {
return EmailAssessment::fromPayload($payload);
} catch (UnexpectedValueException) {
Log::warning('email_validator.contract_mismatch');
return EmailAssessment::deferred('contract_mismatch');
}
}
}
The client makes at most two attempts. It retries connection failures and gateway-style 502, 503, and 504 responses once after a short backoff. It does not retry authentication failures, malformed requests, or 429 responses. Repeating those requests immediately wastes capacity and rarely changes the result.
Persist the graceful state
Add a column that distinguishes a trusted acceptance from a registration allowed during uncertainty:
php artisan make:migration add_email_risk_state_to_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_risk_state', 20)
->default('pending')
->index();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('email_risk_state');
});
}
};
A later scheduled review can revisit pending accounts, but registration itself does not depend on that future enhancement.
Connect the registration controller
Create or adapt app/Http/Controllers/RegisteredUserController.php:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Domain\EmailVerdict;
use App\Models\User;
use App\Services\EmailValidatorClient;
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\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
final class RegisteredUserController extends Controller
{
public function store(
Request $request,
EmailValidatorClient $validator
): RedirectResponse {
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email:rfc', 'max:255',
'unique:users,email'],
'password' => ['required', 'string', 'min:12', 'confirmed'],
]);
$assessment = $validator->check($data['email']);
if ($assessment->verdict === EmailVerdict::Reject) {
throw ValidationException::withMessages([
'email' => 'Please use another email address.',
]);
}
if ($assessment->verdict === EmailVerdict::Defer) {
Log::notice('registration.email_validation_deferred', [
'reason' => $assessment->reason,
]);
}
$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = Hash::make($data['password']);
$user->email_risk_state =
$assessment->verdict === EmailVerdict::Accept
? 'accepted'
: 'pending';
$user->save();
event(new Registered($user));
Auth::login($user);
return redirect('/dashboard');
}
}
Keep the external request outside a database transaction; no database lock should remain open while waiting on the network. The Registered event also preserves Laravel’s normal verified-email workflow when the user model implements email verification.
Register the endpoint in routes/web.php, adapting it if an authentication starter kit already owns these routes:
use App\Http\Controllers\RegisteredUserController;
use Illuminate\Support\Facades\Route;
Route::view('/register', 'auth.register')
->middleware('guest')
->name('register');
Route::post('/register', [RegisteredUserController::class, 'store'])
->middleware(['guest', 'throttle:10,1'])
->name('register.store');
Prove the failure policy with automated tests
Laravel’s Http::fake() keeps tests deterministic and prevents real quota consumption. Add cases like these to tests/Feature/RegistrationTest.php:
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class RegistrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config([
'services.email_validator.token' => 'test-token',
'services.email_validator.retry_delay_ms' => 0,
]);
}
public function test_clear_rejection_blocks_registration(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*'
=> Http::response([
'status' => 'success',
'score' => 10,
'recommendation' => 'reject',
'checks' => ['completed' => true],
'quota' => ['present' => true],
], 200),
]);
$response = $this->post('/register', $this->registrationData());
$response->assertSessionHasErrors('email');
$this->assertDatabaseCount('users', 0);
}
public function test_service_downtime_does_not_reject_user(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*'
=> Http::sequence()
->push([], 503)
->push([], 503),
]);
$response = $this->post('/register', $this->registrationData());
$response->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
'email_risk_state' => 'pending',
]);
}
public function test_complete_acceptance_is_recorded(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*'
=> Http::response([
'status' => 'success',
'score' => 92,
'recommendation' => 'accept',
'checks' => ['completed' => true],
'quota' => ['present' => true],
], 200),
]);
$this->post('/register', $this->registrationData())
->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
'email_risk_state' => 'accepted',
]);
}
private function registrationData(): array
{
return [
'name' => 'Example Reader',
'email' => '[email protected]',
'password' => 'a-long-test-password',
'password_confirmation' => 'a-long-test-password',
];
}
}
These payloads are application-policy fixtures, not claims about undocumented nested check or quota keys. Add further tests for 429, invalid JSON, missing fields, connection exceptions, and unknown recommendations.
Security, observability, and deployment
The token travels in a query parameter because that is the service’s authentication contract. HTTPS protects it in transit, but URLs may still appear in proxy or tracing logs. Configure infrastructure to redact query strings, and never log the request URL, token, email address, or response body. The structured events above expose operational causes without exposing personal data.
Rate-limit registration, retain Laravel’s CSRF protection, keep the endpoint host fixed in configuration, and continue requiring ordinary email ownership verification. Risk validation and ownership verification solve different problems.
Monitor deferred-result counts by reason, authentication failures, HTTP status distribution, and the proportion of accounts left pending. A sudden rise in contract_mismatch deserves investigation; a sustained rise in quota-related deferrals may indicate either abuse or an unsuitable plan.
Deploy the migration before code that writes the new column. Supply the token through the hosting platform’s secret manager, then run:
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan test
After rotating a token, update the environment secret and rerun php artisan config:cache. An old cached value will continue producing authentication failures even when the underlying environment variable is correct.
Common failures to check first
- A
401or403usually points to a missing, revoked, or incorrectly copied service token. Do not retry it in a tight loop. - A
429should produce a pending registration, not a rejection. Review quota information and traffic patterns before changing plans. - Repeated timeouts may indicate DNS, firewall, outbound HTTPS, or proxy problems. Keep the timeout bounded while investigating.
- A contract mismatch means the body lacks a required top-level field or contains an unexpected type. Log the event, not the sensitive body.
- If every result is deferred, compare the documented
statusandrecommendationvocabulary with the local policy mapper.
Final verification checklist
- The token exists only in environment-backed configuration.
- The request uses the exact GET endpoint with URL-encoded
tokenandemailparameters. - Connection and total-response timeouts are bounded.
- Only connection and temporary gateway failures receive one retry.
- Unknown, malformed, quota-limited, and unavailable responses defer registration.
- A rejection occurs only from a complete, recognized response.
- Logs contain reasons and HTTP status codes, but no tokens, addresses, URLs, or bodies.
- Feature tests cover acceptance, rejection, and downtime.
- A manual staging test confirms that API downtime still creates a pending user.
The mature integration is not the one that makes the most API calls. It is the one that knows exactly how much confidence to place in each answer. By making uncertainty a first-class domain outcome, this registration form gains useful protection without turning temporary dependency trouble into a locked front door.