Laravel Registration: Keep Users Signing Up When Email Validation Falters
A registration form has one job: let legitimate people create an account. Email validation improves that flow by filtering obvious mistakes and high-risk addresses, but it becomes counterproductive when a temporary dependency outage locks everyone out.
This tutorial builds a production-oriented Laravel integration that checks syntax, domain, MX records, provider signals, and practical delivery risk through the Email Validator API. Definitive rejection recommendations stop the registration. Timeouts, exhausted quota, rate limits, malformed responses, and temporary server failures produce a structured “unavailable” result and allow registration to continue.
The design is deliberately synchronous because the result affects the current form submission. It is also deliberately fail-open for operational failures. Laravel’s normal email-verification flow should remain the final proof that the applicant controls the address.
Prerequisites and service access
You need PHP 8.3 or newer, a current Laravel application, Composer, a configured database, and Laravel’s standard User model and registration form. The examples use Laravel’s built-in HTTP client, validation, logging, rate limiting, and test fakes; no additional HTTP package is required.
Get service access before writing the integration:
- 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 coordinate rotation with deployment: update the environment secret, redeploy or reload configuration, verify the new token, and only then consider the change complete.
Confirm the exact request
The API call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address is sent in email. Make one minimal request with a non-production test address:
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode '[email protected]' \
--header 'Accept: application/json'
A query-string credential can appear in shell history and infrastructure access logs. Use the command only in a controlled environment, keep the placeholder in shared documentation, and configure proxies and application logs to redact query strings.
Store the real token in Laravel’s environment configuration, never in source control:
# .env
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_URL=https://ai.mihajlo.mk/api/email-validator/v1/check-email
# These are application policy values. Match them to the recommendation
# vocabulary documented for your activated service.
EMAIL_VALIDATOR_SUCCESS_STATUSES=success
EMAIL_VALIDATOR_ALLOW_RECOMMENDATIONS=accept
EMAIL_VALIDATOR_REJECT_RECOMMENDATIONS=reject
Add the corresponding configuration to config/services.php. Reading environment variables here keeps application code compatible with php artisan config:cache.
<?php
return [
// Existing services...
'email_validator' => [
'url' => env(
'EMAIL_VALIDATOR_URL',
'https://ai.mihajlo.mk/api/email-validator/v1/check-email'
),
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'success_statuses' => array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_SUCCESS_STATUSES', 'success'))
)),
'allow_recommendations' => array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_ALLOW_RECOMMENDATIONS', 'accept'))
)),
'reject_recommendations' => array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_REJECT_RECOMMENDATIONS', 'reject'))
)),
],
];
The configurable vocabulary is important. The supplied contract names status, score, recommendation, checks, and quota, but application code should not guess undocumented meanings or score thresholds. Confirm the current documented recommendation values and configure the lists accordingly.
Architecture: authoritative rejection, graceful degradation
The registration controller performs inexpensive local validation first. A dedicated API boundary then translates the remote response into one of three domain outcomes:
- Allow: the response is complete and its recommendation matches the configured allow list.
- Reject: the response is complete and its recommendation matches the configured reject list.
- Unavailable: transport, authentication, quota, schema, status, or unknown-recommendation problems prevent a trustworthy decision.
Only Reject stops registration. Unavailable is logged and fails open. This keeps infrastructure trouble distinct from evidence that an address should be rejected.
Map the API response at one boundary
Create app/Services/EmailValidation/EmailAssessment.php:
<?php
namespace App\Services\EmailValidation;
enum AssessmentVerdict: string
{
case Allow = 'allow';
case Reject = 'reject';
case Unavailable = 'unavailable';
}
final readonly class EmailAssessment
{
public function __construct(
public AssessmentVerdict $verdict,
public ?float $score = null,
public ?string $recommendation = null,
public array $checks = [],
public array $quota = [],
public ?string $failure = null,
) {}
public static function unavailable(string $failure): self
{
return new self(AssessmentVerdict::Unavailable, failure: $failure);
}
}
Now create app/Services/EmailValidation/EmailValidatorClient.php. It uses short, bounded timeouts and retries only connection failures and server-side 5xx responses. It does not blindly retry authentication failures, invalid requests, or HTTP 429 responses.
<?php
namespace App\Services\EmailValidation;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EmailValidatorClient
{
public function check(string $email): EmailAssessment
{
$token = config('services.email_validator.token');
if (! is_string($token) || $token === '') {
Log::critical('email_validator.missing_token');
return EmailAssessment::unavailable('configuration');
}
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(5)
->retry(
3,
200,
function (Exception $exception): bool {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException
&& $exception->response->serverError());
},
throw: false
)
->get(config('services.email_validator.url'), [
'token' => $token,
'email' => $email,
]);
} catch (ConnectionException $exception) {
Log::warning('email_validator.connection_failed', [
'exception' => $exception::class,
]);
return EmailAssessment::unavailable('connection');
} catch (Throwable $exception) {
report($exception);
return EmailAssessment::unavailable('client_exception');
}
if ($response->status() === 429) {
return EmailAssessment::unavailable('rate_limited');
}
if (in_array($response->status(), [401, 403], true)) {
Log::critical('email_validator.authentication_failed');
return EmailAssessment::unavailable('authentication');
}
if (! $response->successful()) {
Log::warning('email_validator.http_failure', [
'http_status' => $response->status(),
]);
return EmailAssessment::unavailable('http_failure');
}
$payload = $response->json();
if (! is_array($payload)) {
return EmailAssessment::unavailable('invalid_json');
}
// Defensively support either direct fields or a conventional data envelope.
$source = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
$status = $source['status'] ?? $payload['status'] ?? null;
$score = $source['score'] ?? null;
$recommendation = $source['recommendation'] ?? null;
$checks = $source['checks'] ?? null;
$quota = $source['quota'] ?? $payload['quota'] ?? null;
if (! is_string($status)
|| ! is_numeric($score)
|| ! is_string($recommendation)
|| ! is_array($checks)
|| $checks === []
|| ! is_array($quota)) {
return EmailAssessment::unavailable('invalid_schema');
}
$status = strtolower(trim($status));
$recommendation = strtolower(trim($recommendation));
$successStatuses = array_map(
'strtolower',
config('services.email_validator.success_statuses', [])
);
if (! in_array($status, $successStatuses, true)) {
return EmailAssessment::unavailable('service_status');
}
$remaining = $this->findRemainingQuota($quota);
if ($remaining !== null && $remaining <= 0) {
return EmailAssessment::unavailable('quota_exhausted');
}
$reject = array_map(
'strtolower',
config('services.email_validator.reject_recommendations', [])
);
$allow = array_map(
'strtolower',
config('services.email_validator.allow_recommendations', [])
);
$verdict = match (true) {
in_array($recommendation, $reject, true) => AssessmentVerdict::Reject,
in_array($recommendation, $allow, true) => AssessmentVerdict::Allow,
default => AssessmentVerdict::Unavailable,
};
Log::info('email_validator.assessed', [
'email_hash' => hash_hmac('sha256', strtolower($email), config('app.key')),
'verdict' => $verdict->value,
'status' => $status,
'score' => (float) $score,
'recommendation' => $recommendation,
'check_count' => count($checks),
'quota_remaining' => $remaining,
]);
return new EmailAssessment(
$verdict,
(float) $score,
$recommendation,
$checks,
$quota,
$verdict === AssessmentVerdict::Unavailable
? 'unknown_recommendation'
: null,
);
}
private function findRemainingQuota(array $quota): ?float
{
foreach ($quota as $key => $value) {
if (strtolower((string) $key) === 'remaining' && is_numeric($value)) {
return (float) $value;
}
if (is_array($value)) {
$remaining = $this->findRemainingQuota($value);
if ($remaining !== null) {
return $remaining;
}
}
}
return null;
}
}
All five response components participate in trust: status must signal success, score must be numeric, recommendation determines the policy result, checks must contain structured evidence, and quota is inspected for exhaustion. The application avoids inventing a score cutoff because no scale or threshold should be assumed without an explicit service contract.
Connect the decision to registration
Create or update app/Http/Controllers/RegisteredUserController.php:
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\EmailValidation\AssessmentVerdict;
use App\Services\EmailValidation\EmailValidatorClient;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
final class RegisteredUserController extends Controller
{
public function store(
Request $request,
EmailValidatorClient $emailValidator
): RedirectResponse {
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', 'min:12'],
]);
$assessment = $emailValidator->check($validated['email']);
if ($assessment->verdict === AssessmentVerdict::Reject) {
return back()
->withInput($request->except('password', 'password_confirmation'))
->withErrors([
'email' => 'Please use a different email address.',
]);
}
if ($assessment->verdict === AssessmentVerdict::Unavailable) {
Log::notice('registration.email_validation_bypassed', [
'reason' => $assessment->failure,
]);
}
$user = DB::transaction(function () use ($validated): User {
$user = new User();
$user->name = $validated['name'];
$user->email = $validated['email'];
$user->password = Hash::make($validated['password']);
$user->save();
return $user;
});
event(new Registered($user));
Auth::login($user);
return redirect()->route('dashboard');
}
}
Keep email_verified_at unset and require Laravel email verification before sensitive actions. API validation estimates address quality; it does not prove ownership.
Protect quota from abusive submissions
Register a named limiter in app/Providers/AppServiceProvider.php, then attach it to the registration route:
<?php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
RateLimiter::for('registration', function (Request $request): array {
$emailKey = hash(
'sha256',
strtolower(trim((string) $request->input('email')))
);
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perHour(10)->by($request->ip().'|'.$emailKey),
];
});
// routes/web.php
Route::middleware(['guest', 'throttle:registration'])
->post('/register', [RegisteredUserController::class, 'store']);
Rate limiting belongs before the paid or quota-limited call. Keep Laravel’s CSRF middleware enabled, validate locally before calling the service, and never include the token, full request URL, raw email, or complete remote response in logs.
Test rejection and failure paths deterministically
Http::fake() prevents tests from consuming quota or depending on the network. The fixture recommendation strings below represent the configured application policy; change them together if the documented service vocabulary differs.
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\EmailValidation\AssessmentVerdict;
use App\Services\EmailValidation\EmailValidatorClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class RegistrationEmailValidationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config([
'services.email_validator.token' => 'test-token',
'services.email_validator.success_statuses' => ['success'],
'services.email_validator.allow_recommendations' => ['accept'],
'services.email_validator.reject_recommendations' => ['reject'],
]);
}
public function test_client_maps_a_rejection(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*' =>
Http::response([
'status' => 'success',
'score' => 12,
'recommendation' => 'reject',
'checks' => ['syntax' => true, 'mx' => false],
'quota' => ['remaining' => 99],
], 200),
]);
$result = app(EmailValidatorClient::class)
->check('[email protected]');
$this->assertSame(AssessmentVerdict::Reject, $result->verdict);
}
public function test_temporary_failure_does_not_reject_registration(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*' =>
Http::response([], 503),
]);
$response = $this->post('/register', [
'name' => 'Casey',
'email' => '[email protected]',
'password' => 'a-long-test-password',
'password_confirmation' => 'a-long-test-password',
]);
$response->assertRedirect(route('dashboard'));
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
]);
Http::assertSentCount(3);
}
public function test_rate_limit_is_not_retried(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*' =>
Http::response([], 429),
]);
$result = app(EmailValidatorClient::class)
->check('[email protected]');
$this->assertSame(AssessmentVerdict::Unavailable, $result->verdict);
Http::assertSentCount(1);
}
}
Add another feature test asserting that a configured rejection redisplays the form, preserves non-secret input, creates no user, and never returns the provider’s internal reason to an attacker.
Deployment and observability
Set the token through your deployment platform’s secret store, verify that production contains the intended recommendation vocabulary, and rebuild Laravel’s caches:
php artisan test
php artisan config:clear
php artisan config:cache
php artisan route:cache
Do not put a live API request in the application health endpoint; frequent probes can consume quota and make deployment health depend on an optional external service. Use a controlled smoke registration in a staging environment instead.
Alert on sustained growth in registration.email_validation_bypassed, authentication failures, rate limits, quota exhaustion, and unknown recommendations. A brief connection failure is ordinary degradation. A persistent authentication error usually means an expired or regenerated token, while an unknown recommendation often signals a contract or configuration change.
Common failure modes
- Every request is unavailable: check cached configuration, token activation, endpoint spelling, outbound HTTPS access, and the configured success status.
- A regenerated token still fails: replace the secret in every running instance and rebuild or restart cached configuration.
- HTTP 429 responses increase: inspect abuse controls and plan quota. Do not amplify the problem with retries.
- Valid-looking responses map to unavailable: compare the actual documented response shape and recommendation vocabulary with the boundary mapper. Keep unknown values fail-open and observable.
- Registration latency rises: inspect connection timing and 5xx retries. Preserve the hard timeout bounds rather than waiting indefinitely.
Final verification checklist
- The exact GET endpoint receives only
tokenandemailquery parameters. - The service token exists only in environment-backed secret configuration.
- Local validation and rate limiting run before the external request.
- Status, score, recommendation, checks, and quota are validated at the API boundary.
- Only configured, authoritative recommendations reject an address.
- Timeouts, 429 responses, quota exhaustion, malformed payloads, and temporary 5xx failures allow registration to continue.
- Logs contain structured operational data but no token or raw email address.
- Email ownership verification still protects sensitive application features.
- Automated tests cover rejection, retry exhaustion, and non-retried rate limiting.
The most reliable registration gate is not the one that pretends dependencies never fail. It is the one that knows the difference between a bad address and a bad afternoon on the network. Preserve that distinction, and email validation strengthens the front door without becoming the lock that traps legitimate users outside.