How to verify email addresses in PHP and Laravel

You verify an email in PHP by making a single server-side POST request to a verification API and reading the status it returns. In plain terms: your form sends the address to your PHP backend, your backend asks the verification service "is this a real, reachable mailbox?", and you accept, flag, or reject the signup based on the answer. Below is complete, copy-paste code for both plain PHP (cURL) and Laravel — with the one rule that keeps your API key safe.

Written & reviewed by the VerifyAnyEmail team · Last reviewed July 2026

Verifying an email address means checking whether it can actually receive mail before you trust it — catching typos like gmial.com, fake addresses, and dead mailboxes at the moment someone signs up. VerifyAnyEmail does this with a live SMTP probe that never sends a real message, and returns a simple status: deliverable, undeliverable, risky, or unknown. Your PHP code just has to make one HTTP call and branch on that word.

Why this has to run on the server

The single most important rule: never call the verification API from client-side code. If you put your key in JavaScript, a browser extension, or a mobile app bundle, anyone can read it and spend your credits. PHP is a server-side language, so you are already in the right place — just make sure the key comes from an environment variable (or Laravel config), never a hard-coded string, and never a value echoed into the page. For the full checklist see how to protect your API keys.

The endpoint is always the same: POST https://api.verifyany.email/v1/verify, authenticated with an Authorization: Bearer vae_... header, with a JSON body of { "email": "name@example.com" }. A successful response looks like { "ok": true, "result": { "status": "deliverable", "score": 98, ... } }. The complete schema is in the API docs.

Plain PHP with cURL

No framework, no dependencies — just cURL, which ships with PHP. This function returns the result object and never throws, so a slow or failing API can never crash your signup flow.

<?php
// verify-email.php — call this from your SERVER, never the browser.
// The API key lives in an environment variable, not in code or the page.

function verifyEmail(string $email): array
{
    $apiKey = getenv('VAE_API_KEY'); // e.g. vae_live_xxx

    $ch = curl_init('https://api.verifyany.email/v1/verify');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,        // seconds — never let a request hang
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode(['email' => $email]),
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err    = curl_error($ch);
    curl_close($ch);

    // Network error or timeout → fail open (treat as "unknown").
    if ($body === false || $err !== '') {
        return ['status' => 'unknown', 'error' => $err ?: 'request_failed'];
    }
    // Rate limited (429) or server error (5xx) → also fail open.
    if ($status === 429 || $status >= 500) {
        return ['status' => 'unknown', 'error' => 'http_' . $status];
    }

    $data = json_decode($body, true);
    if (!($data['ok'] ?? false)) {
        return ['status' => 'unknown', 'error' => $data['error'] ?? 'bad_response'];
    }
    return $data['result']; // ['status' => ..., 'subStatus' => ..., 'score' => ..., ...]
}

$result = verifyEmail('deliverable@test.verifyany.email');

switch ($result['status']) {
    case 'deliverable':
        // Real, reachable mailbox — accept the signup.
        break;
    case 'undeliverable':
        // Mailbox does not exist — block and ask the user to fix it.
        http_response_code(422);
        echo json_encode(['error' => 'Please enter a valid email address.']);
        exit;
    case 'risky':
        // Catch-all / role / disposable — accept, but flag for later review.
        break;
    default: // 'unknown'
        // Timeout, greylisting, temporary failure — fail open, re-verify later.
        break;
}

Prefer Guzzle? Swap the cURL block for $client->post(...) with a timeout option and http_errors => false; the branching on $result['status'] stays identical.

The Laravel way: a service plus a validation rule

In Laravel, use the built-in HTTP client and wrap the call in a small service so it is testable and injectable. First, store the key in config/services.php so it is cached in production:

// config/services.php
'vae' => [
    'key' => env('VAE_API_KEY'),
],

Then the service itself. Notice it degrades to unknown on every failure path — timeouts, 429s, 5xx, malformed JSON — so verification is a helpful signal, never a hard dependency your app can hang on.

<?php
// app/Services/EmailVerifier.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class EmailVerifier
{
    public function verify(string $email): array
    {
        try {
            $response = Http::withToken(config('services.vae.key'))
                ->timeout(10)          // hard cap so a slow probe never blocks a request
                ->acceptJson()
                ->post('https://api.verifyany.email/v1/verify', ['email' => $email]);
        } catch (\Throwable $e) {
            return ['status' => 'unknown']; // connection/timeout → fail open
        }

        if ($response->status() === 429 || $response->serverError()) {
            return ['status' => 'unknown'];
        }
        if (!$response->json('ok')) {
            return ['status' => 'unknown'];
        }
        return $response->json('result'); // status, subStatus, score, suggestion, ...
    }
}

Now expose it as a custom validation rule. This is the idiomatic spot: verification runs as part of normal request validation, and only a confirmed undeliverable result blocks the form.

<?php
// app/Rules/DeliverableEmail.php
namespace App\Rules;

use App\Services\EmailVerifier;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class DeliverableEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $result = app(EmailVerifier::class)->verify($value);

        // Only reject a DEFINITE negative. 'risky' and 'unknown' pass (fail open),
        // so a catch-all domain or a slow API never blocks a real customer.
        if (($result['status'] ?? 'unknown') === 'undeliverable') {
            $fail('That email address does not appear to exist.');
        }
    }
}

Wire it into a FormRequest so the rule runs automatically before your controller even executes:

<?php
// app/Http/Requests/StoreUserRequest.php
namespace App\Http\Requests;

use App\Rules\DeliverableEmail;
use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            // Cheap local checks first; the network probe runs last and only
            // if syntax + DNS already passed.
            'email' => ['required', 'email:rfc,dns', new DeliverableEmail()],
        ];
    }
}

// In the controller, type-hint the request and the rule has already run:
// public function store(StoreUserRequest $request) { User::create($request->validated()); }

How to branch on the result

Four statuses, four actions. Getting this policy right matters more than the code:

  • deliverable — a real, reachable mailbox. Accept it.
  • undeliverable — the mailbox does not exist or the domain cannot receive mail. Block it and show an inline error.
  • risky — a catch-all domain, a role address (info@, support@), or a disposable inbox. Accept it, but store subStatus so you can review or segment later.
  • unknown — the server greylisted the probe or timed out. Fail open: never block a signup on unknown, or you will reject real customers on a temporary hiccup. Re-verify these later in a batch.

This fail-open posture is the whole game at signup — blocking only definite negatives is what keeps verification from hurting conversion. The UX details are in verify emails at signup without hurting conversion.

Testing without spending credits

Every address at @test.verifyany.email returns a deterministic result and costs zero credits — no real mailbox is ever probed. Use them in your test suite and local development:

  • deliverable@test.verifyany.emailstatus: deliverable
  • undeliverable@test.verifyany.emailstatus: undeliverable
  • risky@test.verifyany.emailstatus: risky
  • unknown@test.verifyany.emailstatus: unknown

Write a PHPUnit test for each branch by pointing your verifier at these addresses; you get full coverage of your accept/flag/block logic without touching your credit balance. You can also try any address by hand in the Email Verifier.

Errors, timeouts and retries

The API answers with standard HTTP codes: 400 (malformed input), 401 (bad or missing key), 402 (out of credits), and 429 (rate limited). Handle them deliberately:

  • Always set a timeout (10 seconds is plenty). Both examples above do. A verification call must never be able to hang a web request.
  • Fail open on 429, 5xx and network errors. Return unknown and let the signup through. If you retry inline, retry once at most, with a short backoff.
  • Do not retry a 400 or 401 — those are bugs in your request or key, not transient failures. Log them and fix them.
  • For large lists, don't loop the single-verify endpoint. Use the asynchronous batch endpoint and a signed webhook instead — see processing large verification batches. If you consume that webhook, validate its signature with the Webhook Signature Tester.

Quick FAQ

Does verifying send an email to the user? No. The probe opens an SMTP conversation and stops before any message is delivered, so nothing lands in the recipient's inbox.

Should I block risky addresses? Usually no. Catch-all and role addresses are often legitimate business contacts. Flag and segment them rather than rejecting real users.

Where do I put the key? In .env / getenv() in plain PHP, or config('services.vae.key') in Laravel — never in a Blade template, JavaScript, or version control.

Building in another stack? See the companion guides for Node.js and Express and Python (Django and FastAPI). Full endpoint reference lives in the API documentation.

Sources

PHP HTTP calls use the built-in cURL extension or Guzzle. Laravel specifics are documented in the HTTP Client and Validation guides.

Check your domain in seconds

Run the free diagnostics referenced in this guide — no sign-up needed.