Laravel obrasci za kontakt: pouzdane provjere e-pošte s AI predmemoriranjem i rezervnim rješenjem
A contact form can validate an address perfectly and still accept [email protected]. Laravel’s local validation protects the shape of the input; it cannot tell you whether the domain publishes mail records, whether the provider looks suspicious, or whether practical delivery risk is high.
This tutorial adds that second layer with the Email Validator API. The finished Laravel application performs local validation first, calls the remote service only when necessary, caches successful reports without exposing email addresses in cache keys, and continues through temporary outages using an explicit degraded mode.
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.
This service requires a token. Regenerating it revokes the previously active token, so treat rotation as a deployment change: update every environment that uses the old value before relying on the new deployment.
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address goes in the email query parameter. Make one minimal request before writing application code:
curl --get --silent --show-error --fail-with-body \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
Inspect the documented meanings of the returned status, score, recommendation, checks, and quota fields for your activated plan. The implementation below deliberately avoids guessing undocumented status labels or nested check names.
Store the credential in Laravel’s environment configuration, never in a controller, test fixture, screenshot, or committed file:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_SCORE_FLOOR=70
EMAIL_VALIDATOR_ACCEPT_STATUSES=
EMAIL_VALIDATOR_BLOCKED_RECOMMENDATIONS=
EMAIL_VALIDATOR_REQUIRED_CHECKS=
EMAIL_VALIDATOR_QUOTA_REMAINING_PATH=
EMAIL_VALIDATOR_CACHE_TTL=21600
[email protected]
# Configure the normal Laravel MAIL_* variables for your mail transport.
The score floor is an application policy, not a universal deliverability guarantee. Confirm the score direction and range in the official documentation, then choose a threshold appropriate to your tolerance for false positives. The optional lists let you apply documented status, recommendation, and check semantics without hard-coding assumptions into the client.
Architecture and project layout
You need PHP 8.3 or newer, Composer, a Laravel application, a configured mail transport, and a cache backend. A single-server installation can begin with the database or file cache; multiple application instances should use a shared cache supported by Laravel.
The request path stays synchronous because the visitor needs an immediate answer. Local rules reject malformed input without consuming API quota. The remote report is cached, mapped at one boundary, and evaluated by application policy. Only an accepted or gracefully degraded submission reaches the mail transport.
config/services.phpowns endpoint, token, timeout policy, and decision settings.app/Services/EmailValidator.phpowns transport, mapping, caching, retries, and fallback.app/Http/Requests/ContactRequest.phpperforms local validation.app/Http/Controllers/ContactController.phpapplies the decision and delivers the message.resources/views/contact.blade.phprenders the form.tests/Feature/EmailValidatorTest.phpisolates the integration withHttp::fake().
Add this entry to config/services.php:
'email_validator' => [
'endpoint' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'score_floor' => (float) env('EMAIL_VALIDATOR_SCORE_FLOOR', 70),
'accepted_statuses' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('EMAIL_VALIDATOR_ACCEPT_STATUSES', ''))
))),
'blocked_recommendations' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('EMAIL_VALIDATOR_BLOCKED_RECOMMENDATIONS', ''))
))),
'required_checks' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('EMAIL_VALIDATOR_REQUIRED_CHECKS', ''))
))),
'quota_remaining_path' => env('EMAIL_VALIDATOR_QUOTA_REMAINING_PATH', ''),
'cache_ttl' => (int) env('EMAIL_VALIDATOR_CACHE_TTL', 21600),
],
Build the API boundary
The service retries a connection failure or server error once after a short delay. It does not retry authentication failures, other client errors, or HTTP 429 responses. This prevents a bad token or exhausted allowance from becoming a burst of identical requests.
Successful payloads are cached as arrays and validated again after retrieval. The cache key is an HMAC of the normalized address, so operational tools do not expose the address itself.
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
final readonly class EmailDecision
{
public function __construct(
public bool $accepted,
public bool $degraded,
public string $reason,
public ?float $score = null,
public array $checks = [],
public array $quota = [],
) {}
}
final class EmailValidatorUnavailable extends RuntimeException {}
final class EmailValidator
{
public function assess(string $email): EmailDecision
{
$email = mb_strtolower(trim($email));
$key = 'email-validator:'.hash_hmac(
'sha256',
$email,
(string) config('app.key')
);
try {
$payload = Cache::remember(
$key,
(int) config('services.email_validator.cache_ttl'),
fn (): array => $this->fetch($email)
);
return $this->mapDecision($payload);
} catch (EmailValidatorUnavailable $exception) {
Log::warning('email_validator.degraded', [
'failure' => $exception->getMessage(),
]);
return new EmailDecision(true, true, 'validator_unavailable');
}
}
private function fetch(string $email): array
{
$token = (string) config('services.email_validator.token');
if ($token === '') {
throw new RuntimeException('Email Validator token is not configured.');
}
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(4)
->get(config('services.email_validator.endpoint'), [
'token' => $token,
'email' => $email,
]);
} catch (ConnectionException) {
if ($attempt === 2) {
throw new EmailValidatorUnavailable('connection_failed');
}
usleep(150000);
continue;
}
if ($response->successful()) {
$json = $response->json();
if (! is_array($json)) {
throw new EmailValidatorUnavailable('invalid_json');
}
return $json;
}
if (in_array($response->status(), [401, 403], true)) {
throw new RuntimeException('Email Validator authentication failed.');
}
if ($response->status() === 429) {
throw new EmailValidatorUnavailable('rate_or_quota_limited');
}
if ($response->serverError() && $attempt === 1) {
usleep(150000);
continue;
}
if ($response->serverError()) {
throw new EmailValidatorUnavailable('upstream_server_error');
}
throw new RuntimeException(
'Email Validator returned HTTP '.$response->status().'.'
);
}
throw new EmailValidatorUnavailable('request_failed');
}
private function mapDecision(array $payload): EmailDecision
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (! array_key_exists($field, $payload)) {
throw new EmailValidatorUnavailable('missing_'.$field);
}
}
if (
! is_string($payload['status']) ||
! is_numeric($payload['score']) ||
! is_string($payload['recommendation']) ||
! is_array($payload['checks']) ||
! is_array($payload['quota'])
) {
throw new EmailValidatorUnavailable('malformed_payload');
}
$status = mb_strtolower(trim($payload['status']));
$recommendation = mb_strtolower(trim($payload['recommendation']));
$accepted = (float) $payload['score']
>= (float) config('services.email_validator.score_floor');
$allowed = array_map('mb_strtolower',
config('services.email_validator.accepted_statuses'));
$blocked = array_map('mb_strtolower',
config('services.email_validator.blocked_recommendations'));
if ($allowed !== [] && ! in_array($status, $allowed, true)) {
$accepted = false;
}
if (in_array($recommendation, $blocked, true)) {
$accepted = false;
}
foreach (config('services.email_validator.required_checks') as $path) {
if (data_get($payload['checks'], $path) !== true) {
$accepted = false;
}
}
$quotaPath = (string) config(
'services.email_validator.quota_remaining_path'
);
$remaining = $quotaPath === ''
? null
: data_get($payload['quota'], $quotaPath);
if (is_numeric($remaining) && (float) $remaining <= 0) {
Log::notice('email_validator.quota_exhausted');
}
return new EmailDecision(
$accepted,
false,
$accepted ? 'accepted' : 'rejected',
(float) $payload['score'],
$payload['checks'],
$payload['quota'],
);
}
}
A temporary network failure, 429 response, malformed response, or exhausted service window activates the availability fallback. Authentication and unexpected client errors remain hard failures because silently bypassing a broken deployment configuration could disable validation indefinitely.
Connect validation to the contact form
Create the request and controller. The local email rule is intentionally first: obvious garbage should never reach a paid or quota-limited external service.
<?php
// app/Http/Requests/ContactRequest.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', 'not_regex:/[\r\n]/'],
'email' => ['required', 'string', 'max:254', 'email:rfc'],
'message' => ['required', 'string', 'max:5000'],
];
}
}
<?php
// app/Http/Controllers/ContactController.php
namespace App\Http\Controllers;
use App\Http\Requests\ContactRequest;
use App\Services\EmailValidator;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
final class ContactController
{
public function store(
ContactRequest $request,
EmailValidator $validator
) {
$data = $request->validated();
$decision = $validator->assess($data['email']);
if (! $decision->accepted) {
throw ValidationException::withMessages([
'email' => 'Please provide another email address.',
]);
}
Mail::raw(
"Name: {$data['name']}\nEmail: {$data['email']}\n\n{$data['message']}",
function (Message $mail) use ($data): void {
$mail->to((string) config('mail.contact_to'))
->replyTo($data['email'], $data['name'])
->subject('Website contact');
}
);
return back()->with(
'status',
'Thanks. Your message has been sent.'
);
}
}
Add 'contact_to' => env('CONTACT_TO') to config/mail.php. Then register the routes and apply a basic per-client throttle:
<?php
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 Blade form needs Laravel’s CSRF token and should preserve safe input after a validation failure:
<form method="post" action="{{ route('contact.store') }}">
@csrf
<label>Name <input name="name" value="{{ old('name') }}" required></label>
@error('name') <p>{{ $message }}</p> @enderror
<label>Email <input type="email" name="email"
value="{{ old('email') }}" required></label>
@error('email') <p>{{ $message }}</p> @enderror
<label>Message
<textarea name="message" required>{{ old('message') }}</textarea>
</label>
@error('message') <p>{{ $message }}</p> @enderror
<button type="submit">Send message</button>
</form>
Test decisions, caching, and fallback
External validation tests must be deterministic. Laravel’s HTTP fake verifies the request contract without spending quota or depending on the network.
<?php
namespace Tests\Feature;
use App\Services\EmailValidator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class EmailValidatorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
config([
'services.email_validator.token' => 'test-token',
'services.email_validator.score_floor' => 70,
'services.email_validator.accepted_statuses' => ['ok'],
'services.email_validator.blocked_recommendations' => ['block'],
'services.email_validator.required_checks' => ['mx'],
]);
}
public function test_it_accepts_and_caches_a_qualified_report(): void
{
Http::fake([
'*' => Http::response([
'status' => 'ok',
'score' => 92,
'recommendation' => 'allow',
'checks' => ['mx' => true],
'quota' => ['remaining' => 99],
]),
]);
$service = app(EmailValidator::class);
$this->assertTrue($service->assess('[email protected]')->accepted);
$this->assertTrue($service->assess('[email protected]')->accepted);
Http::assertSentCount(1);
Http::assertSent(fn ($request) =>
$request->method() === 'GET' &&
$request['token'] === 'test-token' &&
$request['email'] === '[email protected]'
);
}
public function test_it_rejects_a_low_score(): void
{
Http::fake(['*' => Http::response([
'status' => 'ok',
'score' => 30,
'recommendation' => 'review',
'checks' => ['mx' => true],
'quota' => [],
])]);
$this->assertFalse(
app(EmailValidator::class)->assess('[email protected]')->accepted
);
}
public function test_it_degrades_after_repeated_server_errors(): void
{
Http::fake(['*' => Http::response([], 503)]);
$decision = app(EmailValidator::class)
->assess('[email protected]');
$this->assertTrue($decision->accepted);
$this->assertTrue($decision->degraded);
Http::assertSentCount(2);
}
}
The fixture labels are test policy values, not claims about the production service vocabulary. Configure the real allowlists and paths from the current official documentation. Also add a controller feature test for the rejected-address validation message and your chosen mail transport.
Security, observability, and deployment
A query-string token deserves special care because web servers, proxies, and application-performance tools may record full URLs. Enforce HTTPS, redact query strings in infrastructure logs, and never log the outgoing request URL. The implementation logs bounded failure identifiers rather than tokens or email addresses.
Keep CSRF protection enabled, retain Laravel’s local input limits, and combine the route throttle with whatever abuse controls the public site already uses. Remote email checks reduce bad addresses; they are not a substitute for spam controls or message sanitization.
For deployment, set the real environment values in the platform’s secret manager, then run:
php artisan config:cache
php artisan route:cache
php artisan test
Ensure the production cache is shared when multiple instances serve traffic. Verify outbound HTTPS access to the API host and run a mail-transport smoke test. Alert on email_validator.degraded, authentication exceptions, and repeated email_validator.quota_exhausted events. A sudden increase in degraded decisions means the form is still operating, but its protection has weakened.
Common failure patterns
- Every request fails authentication: confirm activation, token placement, and whether regeneration revoked the deployed token.
- Changes to environment values have no effect: rebuild Laravel’s configuration cache.
- The API is called repeatedly: confirm that the cache driver is persistent and shared across instances.
- Valid visitors are rejected: revisit the score threshold and documented status, recommendation, and check semantics.
- Timeouts slow the form: retain tight connection and response limits; do not multiply retries across Laravel, a proxy, and an upstream gateway.
Final verification checklist
- The exact GET endpoint receives both
tokenandemailquery parameters. - The token exists only in environment-backed configuration.
- Local validation runs before the external request.
- Successful reports cache by a non-reversible email key.
- Status, score, recommendation, checks, and quota are validated at the boundary.
- Authentication errors fail visibly, while bounded availability failures degrade gracefully.
- Tests cover acceptance, rejection, caching, and repeated upstream failure.
- Logs and monitoring expose operational health without exposing addresses or credentials.
The strongest contact-form validation is not the most aggressive one. It is the one that distinguishes bad input from an unavailable dependency, makes its policy explicit, and remains observable when reality becomes untidy. With that foundation, an external email check becomes a dependable part of the form rather than another fragile network call.