---
title: "How to Validate an Email Address in PHP"
description: "A working PHP client for an email validation API: the cURL extension, a two-second timeout, and branching on all four verdicts — including the typo suggestion that recovers the signup."
slug: "validate-email-in-php"
date: 2026-09-23
updated: 2026-09-23
last_tested: 2026-09-23
summary: "Skip filter_var and the inbox-probing libraries: call /v1/check with ext-curl, fail open on timeouts, and offer the suggested correction instead of rejecting."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "PHP cURL — Client URL Library"
    url: "https://www.php.net/manual/en/book.curl.php"
  - title: "PHP Filters — Validate filters"
    url: "https://www.php.net/manual/en/filter.filters.validate.php"
  - title: "PHP hash — Generate a hash value"
    url: "https://www.php.net/manual/en/function.hash.php"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy"
    url: "https://nobounce.dev/v1/config"
---

The PHP reflex for email validation is `filter_var($email, FILTER_VALIDATE_EMAIL)` — a syntax check and nothing more. It is happy to bless `diego@gmai.com`: the format is fine, the domain is a typo. The other reflex is a heavyweight library promising to confirm the mailbox exists, a claim that depends on SMTP probing — out of scope here, permanently, because it needs sender-IP reputation and returns weak signals at catch-all providers.

The useful answer is an HTTP call that returns a structured verdict plus, when the domain was mistyped, the correction. This is the whole integration in PHP, using the cURL extension that ships enabled on effectively every PHP host.

## Step 1: see the response shape with no account

`POST /demo/check` resolves a frozen fixture corpus — no key, no signup, rate-limited per client:

```bash
curl -X POST https://nobounce.dev/demo/check \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@gmai.com"}'
```

```json
{
  "verdict": "undeliverable",
  "reason": "typosquat_mx",
  "suggestion": "user@gmail.com",
  "confidence": 0.94,
  "checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
  "cached": false
}
```

It is fixture-only: every verdict and reason in the frozen taxonomy is reachable from it, so you can write your branching code against real shapes, but it cannot validate arbitrary real addresses. The full fixture list and the frozen enums are at `GET /v1/config`.

## Step 2: get a key into the environment

There is no free tier. Live DNS checks require a paid key, entry $1/mo for 1,000 checks. You either redeem an operator-minted access code or pay self-serve through the card or stablecoin rail — both are a single `POST /v1/keys` call. The key is shown once; put it in an environment variable (or Laravel's `.env`) and never in a URL or a query parameter:

```php
// .env / environment
// NOBOUNCE_KEY=...
```

## Step 3: the check function

Two decisions matter more than the HTTP mechanics: a short timeout, and treating an unanswered call as *no opinion* rather than a rejection.

```php
<?php

const CHECK_URL = 'https://nobounce.dev/v1/check';

function check_email(string $email): ?array
{
    $handle = curl_init(CHECK_URL);
    curl_setopt_array($handle, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(['email' => $email]),
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . getenv('NOBOUNCE_KEY'),
        ],
        CURLOPT_TIMEOUT_MS     => 2000,
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $body = curl_exec($handle);
    $errno = curl_errno($handle);
    $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
    curl_close($handle);

    if ($errno !== 0 || $body === false) {
        return null; // timeout, DNS failure, transport — no opinion
    }

    $result = json_decode($body, true);
    if (!is_array($result)) {
        return null; // malformed upstream response — no opinion
    }
    if ($status >= 400) {
        // RFC 9457 problem+json: the fix field says what to do.
        error_log((string) ($result['fix'] ?? 'check failed'));
        return null;
    }
    return $result;
}
```

Every non-2xx body carries a `fix` field in plain language — missing key, exhausted quota (`429`), malformed request — so log it instead of retrying the identical call blindly. With `CURLOPT_RETURNTRANSFER` set and `CURLOPT_FAILONERROR` left off, cURL hands you the 4xx body to read rather than swallowing it.

## Step 4: branch on all four verdicts

Four verdicts, and only one of them stops anything:

```php
$result = check_email('diego@gmai.com');

if ($result === null) {
    accept(mxChecked: false);                              // outage ≠ bad address
} elseif ($result['verdict'] === 'undeliverable' && isset($result['suggestion'])) {
    offerCorrection($result['suggestion'], $result['confidence']);
} elseif ($result['verdict'] === 'undeliverable') {
    reject($result['reason']);
} elseif ($result['verdict'] === 'unknown') {
    accept(mxChecked: $result['checked']['mx']);           // DNS did not complete
} else {
    accept(mxChecked: $result['checked']['mx']);           // deliverable, risky
}
```

| verdict | meaning | your code |
|---|---|---|
| `deliverable` | syntax and MX both passed | accept |
| `risky` | deliverable but disposable or a role account (`reason` says which) | your policy decision |
| `unknown` | the check did not complete; `checked.mx` is `false` | accept and annotate, never reject |
| `undeliverable` | will bounce; `reason` is the cause | reject, or offer `suggestion` |

Two branches deserve emphasis. A suggestion is not an error — `undeliverable` with a `suggestion` means you know which address the user meant to type, and offering it with one-click accept (plus a real "keep what I typed" escape hatch, since confidence is a probability) recovers a signup that a plain rejection loses. And `unknown` proceeds: an API timeout or an upstream DNS failure must never become a hard signup failure — the outage math behind that rule is in [Fail Open: Email Validation Must Never Block a Signup](https://nobounce.dev/blog/fail-open-email-validation-signup/).

## Where filter_var still fits

Keep it, but only as a free local gate. `filter_var` costs nothing and catches the worst garbage — a missing `@`, spaces, an empty string — before you spend a paid check on it. It is not the syntax authority: the engine applies RFC 5322 as practised on its first layer, and `filter_var` disagrees with it at the edges. Reject locally what `filter_var` rejects, then let the API be the judge of everything else — domain existence, MX, typos, disposable providers.

## Laravel

The same call with the HTTP client facade: `Http::timeout(2)->withToken(config('services.nobounce.key'))->post(...)` — identical headers, identical verdict handling. Put the function behind a small service class and call it from your registration controller, server-side. The API key must never reach the browser, which also means calling `/v1/check` from a backend route, not from JavaScript.

## Lists, and what happens to the address

For one address per signup, the function above is complete. For a file of thousands, switch to `POST /v1/check/batch` — up to 1,000 addresses per call, results keyed by `email_sha256`, so responses are safe to log — the chunking loop and hash-join are covered in [How to Batch-Validate a List of Email Addresses](https://nobounce.dev/blog/batch-validate-email-list/).

No plaintext address is stored anywhere in the service — anything persisted is SHA-256, domains in clear. If you later learn an address bounced, report it as a hash:

```php
$digest = hash('sha256', 'user@gmail.com');
```

`POST /v1/feedback` accepts only that digest with the event; send a raw address and it returns a 400 rather than hashing it for you. `POST /v1/hash` computes the same digest server-side, in memory, if you need the canonical normalisation applied first.

Cache hits still count against the monthly quota — `cached: true` means the shared domain cache was warm, not that the check was skipped billing-wise. `GET /v1/me` reports usage and remaining quota, and why the suggestion engine runs before the MX lookup is covered in [Suggest the Correction Instead of Rejecting the Signup](https://nobounce.dev/blog/suggest-the-correction-not-rejection/).

The full ordered integration procedure, written for an agent to execute, is at [/integrate.md](https://nobounce.dev/integrate.md).
