Laravel Contact Forms: AI Email Validation with Caching and Graceful Fallbacks
A contact form can fail in two expensive ways: accepting unusable addresses or rejecting a real person because an external validator is temporarily unavailable. Production code must handle both.
This tutorial builds a Laravel contact form that validates an email locally, checks its practical delivery risk through an Email Validator API, caches successful assessments, and degrades gracefully when the service cannot provide a conclusive answer. Accepted and deferred submissions are stored; clearly rejected addresses return a useful validation error.
Get access to the Email Validator
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 service documentation.
- Find the Service token panel and copy the service-scoped token.
- Store that token in your project environment configuration, never in PHP source code.
This service requires authentication. Its token is sent through the token={serviceToken} query parameter. Regenerating the service token revokes the previously active token, so deployments using the old value must be updated together.
Verify the exact HTTP contract
The API call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. It accepts the email and token query parameters and returns status, score, recommendation, checks, and quota data.
Test the credential from a secure terminal before writing application code:
curl --get \
--data-urlencode "[email protected]" \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
Do not paste the resulting URL into tickets or logs: query strings frequently appear in proxy, browser, and monitoring records.
Prerequisites and project configuration
You need PHP 8.3 or newer, Composer, a Laravel application, and a configured database. A shared production cache such as Redis is preferable when multiple application instances serve the form, although Laravel's array cache is convenient for tests.
For a new application, create the project and generate the initial components:
composer create-project laravel/laravel contact-site
cd contact-site
php artisan make:model ContactMessage -m
php artisan make:controller ContactController
php artisan make:test ContactFormTest
Put the endpoint, token, cache lifetime, and your application policy in .env:
EMAIL_VALIDATOR_URL=https://ai.mihajlo.mk/api/email-validator/v1/check-email
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_CACHE_TTL=21600
EMAIL_VALIDATOR_MIN_SCORE=70
The score threshold is an application policy, not an additional API contract. Confirm the score interpretation in the official documentation and calibrate the threshold for your form's tolerance for risk.
Add a dedicated entry to config/services.php. Calling env() only from configuration files keeps the application compatible with Laravel's configuration cache.
<?php
return [
// Existing service configuration...
'email_validator' => [
'url' => env(
'EMAIL_VALIDATOR_URL',
'https://ai.mihajlo.mk/api/email-validator/v1/check-email'
),
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'cache_ttl' => (int) env('EMAIL_VALIDATOR_CACHE_TTL', 21600),
'min_score' => (float) env('EMAIL_VALIDATOR_MIN_SCORE', 70),
],
];
Architecture: decisive results, explicit degradation
The browser submits to Laravel, where ordinary validation first rejects blank or malformed input. A dedicated API boundary then checks the normalized address. Only conclusive API results enter the cache.
The returned fields have separate responsibilities:
scoredrives this application's configurable accept-or-reject policy.statusandrecommendationpreserve the service's conclusion without guessing undocumented string values.checkspreserves detailed evidence for later review.quotaprovides operational context and must be structurally valid before the result is considered conclusive.
The application deliberately treats the contents of checks as opaque. A boolean such as “disposable is false” could be favorable, while “MX exists is false” would not be. Interpreting unnamed subfields without a documented schema would create a subtle production bug.
There are three domain states: accepted, rejected, and deferred. A deferred result means the remote decision was unavailable, not that the visitor supplied a bad address. The message is stored with that state so a transient outage does not discard a genuine enquiry.
Build the defensive API boundary
Create app/Services/EmailValidationAssessment.php and app/Services/EmailValidator.php. The service hashes email addresses in cache keys and logs, applies bounded timeouts, retries a connection failure or server error once, and never retries authentication, malformed-request, or rate-limit responses blindly.
<?php
// app/Services/EmailValidationAssessment.php
namespace App\Services;
final readonly class EmailValidationAssessment
{
public function __construct(
public string $decision,
public ?string $status = null,
public ?float $score = null,
public ?string $recommendation = null,
public array $checks = [],
public array $quota = [],
public ?string $failure = null,
) {}
public static function deferred(string $failure): self
{
return new self('deferred', failure: $failure);
}
}
<?php
// app/Services/EmailValidator.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
use UnexpectedValueException;
final class EmailValidator
{
public function check(string $email): EmailValidationAssessment
{
$email = trim($email);
$emailHash = hash('sha256', strtolower($email));
$cacheKey = 'email-validator:v1:'.$emailHash;
$cached = Cache::get($cacheKey);
if ($cached instanceof EmailValidationAssessment) {
return $cached;
}
$token = config('services.email_validator.token');
if (! is_string($token) || $token === '') {
Log::critical('Email validator token is not configured');
return EmailValidationAssessment::deferred('misconfigured');
}
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(5)
->get(config('services.email_validator.url'), [
'email' => $email,
'token' => $token,
]);
} catch (ConnectionException $exception) {
if ($attempt === 1) {
usleep(200_000);
continue;
}
Log::warning('Email validator connection failed', [
'email_hash' => $emailHash,
'failure' => $exception::class,
]);
return EmailValidationAssessment::deferred('connection');
}
if ($response->serverError() && $attempt === 1) {
usleep(200_000);
continue;
}
if ($response->status() === 429) {
Log::warning('Email validator rate limited the application', [
'email_hash' => $emailHash,
'quota' => $this->quotaFrom($response),
]);
return EmailValidationAssessment::deferred('rate_limited');
}
if (in_array($response->status(), [401, 403], true)) {
Log::critical('Email validator authentication failed', [
'http_status' => $response->status(),
]);
return EmailValidationAssessment::deferred('authentication');
}
if (! $response->successful()) {
Log::warning('Email validator returned an unsuccessful response', [
'email_hash' => $emailHash,
'http_status' => $response->status(),
]);
return EmailValidationAssessment::deferred(
$response->serverError() ? 'upstream' : 'request_rejected'
);
}
try {
$assessment = $this->mapResponse($response);
} catch (Throwable $exception) {
Log::warning('Email validator response was malformed', [
'email_hash' => $emailHash,
'failure' => $exception::class,
]);
return EmailValidationAssessment::deferred('invalid_response');
}
Cache::put(
$cacheKey,
$assessment,
now()->addSeconds(
config('services.email_validator.cache_ttl')
)
);
Log::info('Email validation completed', [
'email_hash' => $emailHash,
'decision' => $assessment->decision,
'status' => $assessment->status,
'score' => $assessment->score,
'recommendation' => $assessment->recommendation,
'quota' => $assessment->quota,
]);
return $assessment;
}
return EmailValidationAssessment::deferred('upstream');
}
private function mapResponse(Response $response): EmailValidationAssessment
{
$data = $response->json();
if (! is_array($data)) {
throw new UnexpectedValueException('Expected a JSON object.');
}
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (! array_key_exists($field, $data)) {
throw new UnexpectedValueException("Missing {$field}.");
}
}
if (
! is_string($data['status']) ||
$data['status'] === '' ||
! is_numeric($data['score']) ||
! is_string($data['recommendation']) ||
$data['recommendation'] === '' ||
! is_array($data['checks']) ||
! is_array($data['quota'])
) {
throw new UnexpectedValueException('Unexpected field types.');
}
$score = (float) $data['score'];
$decision = $score >= config('services.email_validator.min_score')
? 'accepted'
: 'rejected';
return new EmailValidationAssessment(
decision: $decision,
status: $data['status'],
score: $score,
recommendation: $data['recommendation'],
checks: $data['checks'],
quota: $data['quota'],
);
}
private function quotaFrom(Response $response): array
{
$quota = $response->json('quota');
return is_array($quota) ? $quota : [];
}
}
The one retry has a small, bounded backoff. A 429 response instead enters deferred mode immediately: repeatedly consuming a constrained service while it is rate-limiting the application usually makes recovery slower. Authentication failures also return immediately because retrying the same revoked or incorrect token cannot succeed.
Persist the contact and its validation evidence
Use the generated migration to store the message and the domain assessment. The external response remains evidence rather than becoming part of the public error message.
<?php
// database/migrations/..._create_contact_messages_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::create('contact_messages', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->text('message');
$table->string('validation_state', 20);
$table->string('provider_status')->nullable();
$table->decimal('provider_score', 8, 2)->nullable();
$table->string('provider_recommendation')->nullable();
$table->json('provider_checks');
$table->json('provider_quota');
$table->string('validation_failure')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('contact_messages');
}
};
<?php
// app/Models/ContactMessage.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class ContactMessage extends Model
{
protected $fillable = [
'name',
'email',
'message',
'validation_state',
'provider_status',
'provider_score',
'provider_recommendation',
'provider_checks',
'provider_quota',
'validation_failure',
];
protected function casts(): array
{
return [
'provider_score' => 'float',
'provider_checks' => 'array',
'provider_quota' => 'array',
];
}
}
Connect the controller, routes, and form
Local validation runs before the paid or quota-limited remote call. A rejected address returns to the form, while accepted and deferred submissions are retained with different states.
<?php
// app/Http/Controllers/ContactController.php
namespace App\Http\Controllers;
use App\Models\ContactMessage;
use App\Services\EmailValidator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class ContactController extends Controller
{
public function store(
Request $request,
EmailValidator $validator
): RedirectResponse {
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'string', 'email', 'max:254'],
'message' => ['required', 'string', 'max:5000'],
]);
$assessment = $validator->check($data['email']);
if ($assessment->decision === 'rejected') {
return back()
->withErrors([
'email' => 'Please check this email address or use another one.',
])
->withInput();
}
ContactMessage::create([
...$data,
'validation_state' => $assessment->decision,
'provider_status' => $assessment->status,
'provider_score' => $assessment->score,
'provider_recommendation' => $assessment->recommendation,
'provider_checks' => $assessment->checks,
'provider_quota' => $assessment->quota,
'validation_failure' => $assessment->failure,
]);
return back()->with(
'success',
'Thanks. Your message has been received.'
);
}
}
<?php
// routes/web.php
use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;
Route::view('/contact', 'contact')->name('contact.create');
Route::post('/contact', [ContactController::class, 'store'])
->middleware('throttle:10,1')
->name('contact.store');
<!-- resources/views/contact.blade.php -->
@if (session('success'))
<p>{{ session('success') }}</p>
@endif
<form method="post" action="{{ route('contact.store') }}">
@csrf
<label for="name">Name</label>
<input id="name" name="name" value="{{ old('name') }}" required>
@error('name') <p>{{ $message }}</p> @enderror
<label for="email">Email</label>
<input id="email" name="email" type="email"
value="{{ old('email') }}" required>
@error('email') <p>{{ $message }}</p> @enderror
<label for="message">Message</label>
<textarea id="message" name="message"
required>{{ old('message') }}</textarea>
@error('message') <p>{{ $message }}</p> @enderror
<button type="submit">Send message</button>
</form>
Run php artisan migrate, then style the Blade template within your existing layout. No queue is necessary here: the visitor needs an immediate result, and the bounded five-second response timeout prevents an indefinitely hanging request.
Test decisions, caching, and fallback behavior
Laravel's Http::fake() keeps the suite deterministic and ensures no test consumes quota. The fixture strings below are deliberately opaque; the application does not claim that they are real provider enum values.
<?php
// tests/Feature/ContactFormTest.php
namespace Tests\Feature;
use App\Models\ContactMessage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ContactFormTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config([
'cache.default' => 'array',
'services.email_validator.token' => 'test-token',
'services.email_validator.min_score' => 70,
]);
Cache::flush();
}
public function test_it_accepts_and_caches_a_conclusive_result(): void
{
Http::fake([
'*' => Http::response([
'status' => 'test-status',
'score' => 91,
'recommendation' => 'test-recommendation',
'checks' => ['test-check' => true],
'quota' => ['test-quota' => 10],
]),
]);
$payload = [
'name' => 'Taylor',
'email' => '[email protected]',
'message' => 'Please send project details.',
];
$this->post('/contact', $payload)->assertSessionHas('success');
$this->post('/contact', $payload)->assertSessionHas('success');
Http::assertSentCount(1);
Http::assertSent(fn ($request) =>
$request->method() === 'GET' &&
$request['email'] === '[email protected]' &&
$request['token'] === 'test-token'
);
$this->assertDatabaseCount('contact_messages', 2);
}
public function test_it_rejects_a_result_below_the_policy_score(): void
{
Http::fake([
'*' => Http::response([
'status' => 'test-status',
'score' => 20,
'recommendation' => 'test-recommendation',
'checks' => [],
'quota' => [],
]),
]);
$this->post('/contact', [
'name' => 'Taylor',
'email' => '[email protected]',
'message' => 'Hello',
])->assertSessionHasErrors('email');
$this->assertDatabaseCount('contact_messages', 0);
}
public function test_it_stores_a_deferred_message_during_an_outage(): void
{
Http::fakeSequence()
->pushStatus(503)
->pushStatus(503);
$this->post('/contact', [
'name' => 'Taylor',
'email' => '[email protected]',
'message' => 'Hello',
])->assertSessionHas('success');
$this->assertDatabaseHas('contact_messages', [
'email' => '[email protected]',
'validation_state' => 'deferred',
'validation_failure' => 'upstream',
]);
}
}
Run the focused suite with php artisan test --filter=ContactFormTest. Additional tests should cover malformed JSON, missing fields, authentication failure, rate limiting, local validation, and an empty token.
Security, observability, and deployment
The service token grants access to quota and must be managed like any other production secret. Keep it out of source control, exception dumps, request URLs in monitoring systems, screenshots, and test fixtures. Because the authentication contract uses a query parameter, configure reverse proxies and application-performance tools to redact query strings for this endpoint.
Email addresses are personal data. The implementation uses a SHA-256 digest in cache keys and logs, while the database stores the address only because responding to the contact request requires it. Apply an appropriate retention policy and restrict access to the stored provider evidence.
Monitor the count of accepted, rejected, and deferred states, plus failures grouped by reason. An increase in authentication failures usually indicates a missing, incorrect, or regenerated token. Sustained rate_limited results indicate that traffic, caching, or plan capacity needs attention. Avoid live email checks in health probes because they consume service capacity.
For deployment:
- Provide
EMAIL_VALIDATOR_TOKENthrough the hosting platform's secret manager. - Run
php artisan migrate --force. - Run
php artisan config:cacheafter environment values are available. - Use a shared cache driver when requests can reach multiple application instances.
- Submit one controlled test contact, inspect its stored state, and verify that logs contain no email address or token.
Common production failures
- Every result is deferred as misconfigured: confirm the environment variable exists, then rebuild Laravel's configuration cache.
- Authentication suddenly fails: a regenerated token revoked the previous token. Update every active deployment using it.
- Validation calls occur on every submission: verify the production cache driver is persistent and shared, and confirm that only conclusive results are expected to be cached.
- Valid visitors are rejected: review the documented score semantics and adjust the business threshold. Do not infer meaning from undocumented check names.
- The form becomes slow during incidents: confirm the two-second connection and five-second response timeouts are still applied and that infrastructure is not adding its own long retry chain.
Final verification checklist
- The request uses the exact
GETendpoint withemailandtokenquery parameters. - The token comes from environment-backed configuration and is absent from logs and source control.
- Local Laravel validation runs before the external request.
- All five response areas are validated and mapped at the API boundary.
- Conclusive results are cached under a hashed key; failures are not cached.
- Connection and response timeouts are bounded, and only transient failures receive one retry.
- Authentication and rate-limit responses are not blindly retried.
- Rejected addresses receive a useful error, while outages preserve the contact as deferred.
- Automated tests make no real network calls.
- Production metrics distinguish address decisions from integration failures.
Email validation earns its place in a contact form only when it improves data quality without becoming a new point of data loss. The durable design is not simply “call an API.” It is to isolate the contract, cache what is trustworthy, preserve uncertainty explicitly, and let a temporary dependency failure remain temporary.