Laravel Contact Forms: Foolproof Email Validation with Caching and Fallback
A contact form should reject obvious dead ends without becoming dependent on another service to accept legitimate messages. That tension is the heart of a production-ready email check. Syntax validation alone misses nonexistent domains and risky delivery signals, while a strict remote dependency can turn a provider outage into a broken contact page.
This tutorial builds a Laravel contact form that calls an email-validation service, translates its response into an application-level decision, caches successful assessments, and fails open when validation is temporarily unavailable. The form still uses Laravel’s local validation as its first line of defense, and it never exposes the service token to the browser.
Get access and copy the service token
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 it in Laravel’s environment configuration, never in committed PHP or JavaScript.
This service requires a token. Authentication uses the token={serviceToken} query parameter. Regenerating the token revokes the previously active token, so token rotation must include updating every deployed environment that uses it.
Confirm the endpoint before writing Laravel code
The exact API call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. It accepts the email and token query parameters.
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode '[email protected]' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN'
Inspect this response alongside the official documentation before choosing business thresholds. The integration below consumes status, score, recommendation, checks, and quota, but validates their types instead of assuming that every successful HTTP response has a usable body.
Add deployment-specific values to .env:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_MIN_SCORE=60
EMAIL_VALIDATOR_DENY_RECOMMENDATIONS=reject,invalid,undeliverable
EMAIL_VALIDATOR_CACHE_HOURS=12
[email protected]
The score threshold and deny list are application policy, not a claim that those are the service’s only possible recommendation values. Align them with the current documentation, your test response, and your tolerance for false rejections.
Architecture: strict at the boundary, resilient at the form
The request follows a deliberately short path:
- Laravel validates the name, email syntax, and message locally.
- A dedicated client normalizes the email and checks a hashed cache key.
- On a cache miss, the server calls the validator with bounded timeouts.
- A DTO validates and maps the external response.
- The policy returns
allow,deny, orunavailable. denyreturns a field error;unavailableaccepts the message under the graceful-fallback policy.
Failing open is appropriate for an ordinary contact form because losing a genuine enquiry is usually worse than receiving one questionable address. Password resets, account ownership checks, and financial workflows should use a different policy.
The relevant project files are config/services.php, app/Services/EmailValidator.php, app/Data/EmailAssessment.php, app/Http/Requests/ContactRequest.php, app/Http/Controllers/ContactController.php, app/Mail/ContactMessage.php, the Blade views, routes/web.php, and a feature test.
Configure the service boundary
Add the following entry inside the array returned by config/services.php:
'email_validator' => [
'url' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'minimum_score' => (float) env('EMAIL_VALIDATOR_MIN_SCORE', 60),
'deny_recommendations' => array_values(array_filter(array_map(
'trim',
explode(',', env(
'EMAIL_VALIDATOR_DENY_RECOMMENDATIONS',
'reject,invalid,undeliverable'
))
))),
'cache_hours' => (int) env('EMAIL_VALIDATOR_CACHE_HOURS', 12),
],
Configuration indirection makes Laravel’s configuration cache safe to use and keeps secrets out of source control. Do not call env() from application classes.
Map the response into a domain result
Create app/Data/EmailAssessment.php. The mapper accepts either top-level fields or fields inside a data object, then rejects malformed responses at the application boundary.
<?php
namespace App\Data;
final readonly class EmailAssessment
{
public function __construct(
public string $decision,
public ?string $status = null,
public int|float|null $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('unavailable', failure: $failure);
}
public static function fromPayload(array $json): self
{
$data = is_array($json['data'] ?? null) ? $json['data'] : [];
$body = array_merge($json, $data);
$status = $body['status'] ?? null;
$score = $body['score'] ?? null;
$recommendation = $body['recommendation'] ?? null;
$checks = $body['checks'] ?? null;
$quota = $body['quota'] ?? null;
if (! is_scalar($status)
|| ! is_numeric($score)
|| ! is_string($recommendation)
|| $recommendation === ''
|| ! is_array($checks)
|| $checks === []
|| ! is_array($quota)) {
return self::unavailable('malformed_response');
}
$normalizedStatus = strtolower((string) $status);
$normalizedRecommendation = strtolower(trim($recommendation));
$deny = array_map(
fn (string $value) => strtolower($value),
config('services.email_validator.deny_recommendations', [])
);
$remaining = data_get($quota, 'remaining');
if (in_array($normalizedStatus, ['error', 'failed', 'failure'], true)) {
return self::unavailable('service_status');
}
if (is_numeric($remaining) && (float) $remaining <= 0) {
return self::unavailable('quota_exhausted');
}
$decision = (float) $score
< (float) config('services.email_validator.minimum_score')
|| in_array($normalizedRecommendation, $deny, true)
? 'deny'
: 'allow';
return new self(
decision: $decision,
status: (string) $status,
score: (float) $score,
recommendation: $recommendation,
checks: $checks,
quota: $quota,
);
}
}
The full checks structure remains available for future policy refinement without coupling the form to undocumented nested fields. A malformed result becomes unavailable, never an accidental rejection.
Call the API with caching, timeouts, and selective retries
Create app/Services/EmailValidator.php:
<?php
namespace App\Services;
use App\Data\EmailAssessment;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class EmailValidator
{
public function check(string $email): EmailAssessment
{
$normalized = strtolower(trim($email));
$cacheKey = 'email-validator:'.hash('sha256', $normalized);
if ($cached = Cache::get($cacheKey)) {
return $cached;
}
$token = config('services.email_validator.token');
if (! is_string($token) || $token === '') {
Log::error('Email validator token is not configured');
return EmailAssessment::unavailable('missing_token');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(5)
->get(config('services.email_validator.url'), [
'email' => $normalized,
'token' => $token,
]);
} catch (ConnectionException) {
if ($attempt < 3) {
usleep($attempt === 1 ? 100_000 : 300_000);
continue;
}
Log::warning('Email validator connection failed', [
'email_hash' => substr(hash('sha256', $normalized), 0, 12),
]);
return EmailAssessment::unavailable('connection_failed');
}
if (in_array($response->status(), [500, 502, 503, 504], true)
&& $attempt < 3) {
usleep($attempt === 1 ? 100_000 : 300_000);
continue;
}
if ($response->status() === 429) {
Log::warning('Email validator rate or quota limit reached');
return EmailAssessment::unavailable('rate_limited');
}
if (! $response->successful()) {
Log::warning('Email validator rejected the request', [
'http_status' => $response->status(),
]);
return EmailAssessment::unavailable('http_'.$response->status());
}
$json = $response->json();
$assessment = is_array($json)
? EmailAssessment::fromPayload($json)
: EmailAssessment::unavailable('invalid_json');
if ($assessment->decision !== 'unavailable') {
Cache::put(
$cacheKey,
$assessment,
now()->addHours(
config('services.email_validator.cache_hours', 12)
)
);
}
Log::info('Email validation completed', [
'decision' => $assessment->decision,
'status' => $assessment->status,
'score' => $assessment->score,
'recommendation' => $assessment->recommendation,
]);
return $assessment;
}
return EmailAssessment::unavailable('unexpected_failure');
}
}
Only connection failures and likely transient server failures are retried. Authentication errors, invalid requests, and quota responses are not blindly repeated. Failed assessments are not cached, allowing recovery as soon as the service becomes available.
Connect the validator to the contact form
Create the request with php artisan make:request ContactRequest, then define its rules:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class ContactRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email:rfc', 'max:254'],
'message' => ['required', 'string', 'min:10', 'max:5000'],
];
}
}
Create a standard Laravel mailable named ContactMessage whose constructor exposes $senderName, $senderEmail, and $body, and whose content view is mail.contact. In that Blade view, print values with escaped Blade expressions such as {{ $senderEmail }}; do not render the message with unescaped syntax.
The controller performs remote validation before sending:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ContactRequest;
use App\Mail\ContactMessage;
use App\Services\EmailValidator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
final class ContactController extends Controller
{
public function store(
ContactRequest $request,
EmailValidator $validator
): RedirectResponse {
$data = $request->validated();
$assessment = $validator->check($data['email']);
if ($assessment->decision === 'deny') {
return back()->withInput()->withErrors([
'email' => 'Please provide another deliverable email address.',
]);
}
if ($assessment->decision === 'unavailable') {
Log::notice('Contact accepted with validation fallback', [
'reason' => $assessment->failure,
]);
}
Mail::to(config('contact.to'))->send(new ContactMessage(
senderName: $data['name'],
senderEmail: $data['email'],
body: $data['message'],
));
return back()->with('status', 'Thanks. Your message has been sent.');
}
}
Create config/contact.php returning ['to' => env('CONTACT_TO')]. Register the routes with abuse protection:
use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;
Route::view('/contact', 'contact')->name('contact');
Route::post('/contact', [ContactController::class, 'store'])
->middleware('throttle:10,1')
->name('contact.store');
The resources/views/contact.blade.php form should post to route('contact.store'), include Laravel’s @csrf, render validation errors, and use old() values. CSRF protection, throttling, length limits, and escaped output cover the basic contact-form attack surface.
Test decisions, caching, and fallback
Laravel’s HTTP fake keeps tests deterministic and ensures no service token leaves the test process.
<?php
namespace Tests\Feature;
use App\Mail\ContactMessage;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
final class ContactTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
config([
'services.email_validator.token' => 'test-token',
'services.email_validator.minimum_score' => 60,
'services.email_validator.deny_recommendations' => ['invalid'],
'contact.to' => '[email protected]',
]);
}
public function test_safe_email_is_checked_cached_and_sent(): void
{
Mail::fake();
Http::fake([
'https://ai.mihajlo.mk/api/email-validator/v1/check-email*' =>
Http::response([
'status' => 'success',
'score' => 92,
'recommendation' => 'safe',
'checks' => ['assessment_available' => true],
'quota' => ['remaining' => 20],
]),
]);
$payload = [
'name' => 'Ada',
'email' => '[email protected]',
'message' => 'Please send the project details.',
];
$this->post('/contact', $payload)->assertSessionHas('status');
$this->post('/contact', $payload)->assertSessionHas('status');
Http::assertSentCount(1);
Http::assertSent(fn ($request) =>
$request['email'] === '[email protected]'
&& $request['token'] === 'test-token'
);
Mail::assertSent(ContactMessage::class, 2);
}
public function test_low_score_is_rejected(): void
{
Mail::fake();
Http::fake([
'*' => Http::response([
'status' => 'success',
'score' => 25,
'recommendation' => 'invalid',
'checks' => ['assessment_available' => true],
'quota' => ['remaining' => 19],
]),
]);
$this->post('/contact', [
'name' => 'Ada',
'email' => '[email protected]',
'message' => 'This message is long enough.',
])->assertSessionHasErrors('email');
Mail::assertNothingSent();
}
public function test_service_failure_falls_back_to_accepting_message(): void
{
Mail::fake();
Http::fake(['*' => Http::response([], 503)]);
$this->post('/contact', [
'name' => 'Ada',
'email' => '[email protected]',
'message' => 'Please send the project details.',
])->assertSessionHas('status');
Mail::assertSent(ContactMessage::class);
}
}
Run the suite with php artisan test. The fixtures deliberately contain only fake credentials and synthetic response data.
Deployment, observability, and common failures
Use a shared cache such as Redis when the application runs on multiple instances; otherwise each node will make its own validation calls. After setting production environment values, run php artisan config:cache. Ensure the configured mail transport works before enabling the public route.
Monitor validation decisions, fallback counts, HTTP status codes, latency at your infrastructure layer, and remaining quota when that value is present. Never log the token, full request URL, raw email, message body, or complete API response. Because authentication is carried in the query string, review proxy and HTTP-client logging so query parameters are redacted.
Frequent 401 or 403 responses usually indicate a missing, revoked, or incorrectly deployed token. A 429 should trigger quota and traffic investigation, not aggressive retries. Repeated malformed-response fallbacks indicate contract drift or an unexpected error envelope. If every request misses the cache, confirm the production cache driver, permissions, TTL, and whether deployments share the same cache.
Final verification checklist
- The service plan is active and the current service-scoped token is present only in environment configuration.
- The GET request sends both
emailandtokenquery parameters to the exact endpoint. - Local validation runs before the remote request.
- Status, score, recommendation, checks, and quota are mapped defensively.
- Only completed assessments are cached under hashed email keys.
- Authentication, validation, and quota failures are not blindly retried.
- A validator outage permits the contact message while emitting a structured fallback log.
- CSRF protection, throttling, safe output escaping, configured mail delivery, and automated tests are in place.
A robust contact form is not the one with the most aggressive filter. It is the one that makes a careful decision when evidence is available, preserves the user’s path when it is not, and leaves enough operational evidence to tell the difference.