Build a PHP contact form

Check submitted email and phone values, keep valid entries after errors, and hand the message to your existing PHP mail transport.

Working example

ParseAPI
PHP

Set up ParseAPI

Install the SDK in your application and set PARSEAPI_KEY in your server environment.

Terminal
composer require parseapi/sdk:1.12.0
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'

Create a client and reuse it for the calls below:

PHP
require __DIR__ . '/vendor/autoload.php';

$parse = new \ParseAPI\Client();
Get an API key ↗

Build the form and its server handler

A contact form needs more than a successful browser check. Build a PHP page that validates the submitted contact details on the server, shows errors beside the relevant fields, and keeps the person's other entries. A valid submission can then use your existing mail transport.

1. Keep the submitted values

The form collects a name, email, optional phone number and message. A national phone number also needs its country. Keep these original values when re-rendering an error; use the normalized results for the message your application prepares.

The handler keeps submitted text in values. Render it back into the field with HTML escaping, while marking only the field that needs correction:

HTML
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" maxlength="254"
       value="<?= htmlspecialchars($values['email'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
       aria-invalid="<?= isset($errors['email']) ? 'true' : 'false' ?>"
       aria-describedby="email-error" required>

The complete app includes a session-bound CSRF token, input limits and HTML escaping. Neither browser validation nor a hidden field replaces the server checks.

2. Check the email on the server

The official SDK client reads the secret PARSEAPI_KEY from your server environment:

PHP
$parse = new \ParseAPI\Client(timeout: 10.0, retries: 0);

Call Email with the submitted address:

PHP
$email = $parse->email(trim($values['email']));

Read the parsed address and validation facts:

PHP
$replyTo = $email['email'];
$valid = $email['valid'];
$domainValid = $email['domain_valid'];

This app requires a valid address and a confirmed domain result before handing a message to its mail transport. A null domain result asks the person to try again; it is not an invalid-address verdict. A spelling suggestion stays separate until the person edits the field.

Core validation does not establish mailbox delivery or ownership. This form does not request metered mailbox verification. See the Email reference.

3. Normalize the optional phone

Skip the lookup when the phone field is empty. For a national number, pass the selected country; a number beginning with + carries its own calling code.

PHP
$phone = $parse->phone(trim($values['phone']), $phoneCountry);

The returned international number can go into the message body:

PHP
$normalizedPhone = $phone['valid'] ? $phone['phone'] : null;

An invalid phone result stays a field error. A failed request leaves the details unchecked and sends nothing. Phone parsing does not establish ownership or current reachability.

4. Use your existing mail transport

Use your configured sender address for From, and the checked reply address for Reply-To:

PHP
$headers = ['From' => $delivery['from'], 'Reply-To' => $replyTo,
    'Content-Type' => 'text/plain; charset=UTF-8'];

The complete example uses PHP's existing mail transport:

PHP
$accepted = mail($to, 'Website contact request', $body, $headers);

Only a true return value produces the acceptance message. Transport acceptance does not guarantee inbox delivery. Failed validation, a failed request, missing mail configuration or rejected delivery keeps the original form entries visible.

Set CONTACT_TO and CONTACT_FROM to addresses you control only when your existing PHP mail transport is configured and you intend to accept submissions. Without those settings, the app never claims to have sent a message.

Each submission uses one Email request and, when supplied, one Phone request. The app makes one attempt per lookup with a ten-second timeout. Errors stop the submission before delivery. The sample phone-country list is deliberately short; replace it with the countries your form serves.

Complete example

Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.

View and copy the complete source
php-contact-form.php
<?php
declare(strict_types=1);

namespace ContactForm;

use ParseAPI\Client;
use ParseAPI\ParseAPIError;

require_once __DIR__ . '/vendor/autoload.php';

const COUNTRIES = ['' => 'Choose for a national number', 'US' => 'United States', 'CA' => 'Canada',
    'GB' => 'United Kingdom', 'AU' => 'Australia', 'DE' => 'Germany', 'FR' => 'France',
    'IN' => 'India', 'JP' => 'Japan'];

function escaped(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/** The mail callable uses the application's existing transport, not ParseAPI. */
function submitContact(array $values, Client $parse, callable $send, array $delivery): array
{
    $errors = [];
    $answer = ['errors' => [], 'error' => null, 'suggestion' => null, 'accepted' => false, 'status' => 422];
    foreach (['name', 'email', 'phone', 'country', 'message'] as $field) {
        if (!isset($values[$field]) || !is_string($values[$field]) || preg_match('//u', $values[$field]) !== 1) {
            $errors[$field] = 'Enter a valid text value.';
        }
    }
    if ($errors) return [...$answer, 'errors' => $errors];
    if (trim($values['name']) === '' || strlen($values['name']) > 400
        || preg_match_all('/./us', $values['name']) > 100
        || preg_match('/[\x00-\x1f\x7f]/', $values['name'])) {
        $errors['name'] = 'Enter your name, up to 100 characters.';
    }
    if (trim($values['email']) === '' || strlen($values['email']) > 254
        || preg_match('/[\x00-\x20\x7f]/', trim($values['email'])) || preg_match('/[\r\n]/', $values['email'])) {
        $errors['email'] = 'Enter one email address without spaces or line breaks.';
    }
    if (strlen($values['phone']) > 100 || preg_match('/[\x00-\x1f\x7f]/', $values['phone'])) {
        $errors['phone'] = 'Enter one phone number, up to 100 characters.';
    }
    if (!array_key_exists($values['country'], COUNTRIES)) $errors['country'] = 'Choose a listed phone country.';
    if (trim($values['phone']) !== '' && !str_starts_with(trim($values['phone']), '+') && $values['country'] === '') {
        $errors['country'] = 'Choose the country for this national phone number.';
    }
    if (trim($values['message']) === '' || strlen($values['message']) > 8000
        || preg_match_all('/./us', $values['message']) > 2000 || str_contains($values['message'], "\0")) {
        $errors['message'] = 'Enter a message of 1 to 2,000 characters.';
    }
    if ($errors) return [...$answer, 'errors' => $errors];
    $normalizedPhone = null;
    try {
        // example:email
        $email = $parse->email(trim($values['email']));
        // /example:email
        if (!isset($email['email']) || !is_string($email['email'])
            || strcasecmp($email['email'], trim($values['email'])) !== 0
            || !isset($email['valid']) || !is_bool($email['valid'])
            || !array_key_exists('domain_valid', $email)
            || !in_array($email['domain_valid'], [true, false, null], true)) {
            throw new \UnexpectedValueException('Unexpected email response.');
        }
        // example:fields
        $replyTo = $email['email'];
        $valid = $email['valid'];
        $domainValid = $email['domain_valid'];
        // /example:fields
        if (!$valid || $domainValid === false) $errors['email'] = 'Check the email address and try again.';
        elseif ($domainValid === null) $errors['email'] = 'The email domain could not be checked. Please try again.';
        if (isset($email['didyoumean']) && is_string($email['didyoumean'])) $answer['suggestion'] = $email['didyoumean'];
        if ($errors) return [...$answer, 'errors' => $errors];
        if (!filter_var($replyTo, FILTER_VALIDATE_EMAIL) || preg_match('/[\r\n]/', $replyTo)) {
            throw new \UnexpectedValueException('Reply address is unsuitable for this mail transport.');
        }
        if (trim($values['phone']) !== '') {
            $phoneCountry = str_starts_with(trim($values['phone']), '+') ? null : $values['country'];
            // example:phone
            $phone = $parse->phone(trim($values['phone']), $phoneCountry);
            // /example:phone
            if (!isset($phone['valid']) || !is_bool($phone['valid']) || !array_key_exists('phone', $phone)
                || ($phone['valid'] && (!is_string($phone['phone']) || !preg_match('/^\+[1-9][0-9]{1,14}$/D', $phone['phone'])))) {
                throw new \UnexpectedValueException('Unexpected phone response.');
            }
            // example:normalized
            $normalizedPhone = $phone['valid'] ? $phone['phone'] : null;
            // /example:normalized
            if (!$phone['valid']) return [...$answer, 'errors' => ['phone' => 'Check the phone number and its country.']];
        }
    } catch (ParseAPIError | \UnexpectedValueException $error) {
        return [...$answer, 'status' => 503, 'error' => 'Your details could not be checked. Nothing was sent. Please try again later.'];
    } catch (\Throwable $error) {
        return [...$answer, 'status' => 503, 'error' => 'Your details could not be checked. Nothing was sent. Please try again later.'];
    }
    if (!filter_var($delivery['to'] ?? '', FILTER_VALIDATE_EMAIL)
        || !filter_var($delivery['from'] ?? '', FILTER_VALIDATE_EMAIL)
        || preg_match('/[\r\n]/', ($delivery['to'] ?? '') . ($delivery['from'] ?? ''))) {
        return [...$answer, 'status' => 503, 'error' => 'Message delivery is unavailable. Nothing was sent. Please try again later.'];
    }
    $body = "Name: " . $values['name'] . "\nReply email: " . $replyTo
        . ($normalizedPhone ? "\nPhone: " . $normalizedPhone : '') . "\n\n" . $values['message'];
    // example:headers
    $headers = ['From' => $delivery['from'], 'Reply-To' => $replyTo,
        'Content-Type' => 'text/plain; charset=UTF-8'];
    // /example:headers
    try {
        $accepted = $send($delivery['to'], $body, $headers) === true;
    } catch (\Throwable $error) {
        $accepted = false;
    }
    return [...$answer, 'accepted' => $accepted, 'status' => $accepted ? 200 : 503,
        'error' => $accepted ? null : 'Delivery could not be confirmed. Your entries are still here.'];
}

function renderContact(array $values, array $answer, string $csrf, bool $accepted): void
{
    $errors = $answer['errors'];
    ?>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,"><title>Contact us</title><style>
:root{color-scheme:light dark;--bg:#f7f8fa;--panel:#fff;--text:#17191d;--muted:#595e68;--line:#d5d9e0;--accent:#2459d3;--error:#a51d2d}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(100% - 40px,680px);margin:72px auto}h1{font-size:clamp(2rem,6vw,2.8rem);line-height:1.12;letter-spacing:-.045em;margin:0 0 18px}p{margin:0 0 20px}.intro,.hint{color:var(--muted)}form{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:28px;margin-top:28px}.row{display:grid;grid-template-columns:1fr 1fr;gap:18px}
label{display:block;font-size:.9rem;font-weight:600;margin-bottom:8px}input,textarea,select,button{font:inherit;min-height:46px;border-radius:9px}input,textarea,select{width:100%;min-width:0;color:var(--text);background:var(--panel);border:1px solid var(--line);padding:10px 12px}textarea{resize:vertical;min-height:150px}select{appearance:none;padding-right:36px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23777' stroke-width='1.5'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-position:right 12px center;background-repeat:no-repeat}
.field{margin-bottom:20px}button{background:var(--accent);color:#fff;border:0;padding:11px 22px;font-weight:600;cursor:pointer}button:hover{filter:brightness(.93)}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}.error{color:var(--error);font-size:.9rem;margin:8px 0 0}[aria-invalid=true]{border-color:var(--error)}.notice{padding:18px;border:1px solid var(--line);border-radius:12px;margin-top:24px}.notice.error{border-color:var(--error)}.hint{font-size:.9rem;margin:8px 0 0}.suggestion{font-size:.9rem;margin:12px 0 0}
@media(max-width:480px){main{margin:40px auto}.row{grid-template-columns:1fr;gap:0}form{padding:22px}}
@media(prefers-color-scheme:dark){:root{--bg:#111316;--panel:#1b1e23;--text:#f3f4f6;--muted:#b3b8c3;--line:#444a55;--accent:#759cff;--error:#ff9ca8}button{color:#111316}}
</style></head><body><main><h1>Contact us</h1><p class="intro">Tell us what you need. We’ll use your email address to reply.</p>
<?php if ($accepted): ?><p class="notice" role="status">Your message was accepted for delivery. Thank you.</p><?php endif; ?>
<?php if ($answer['error']): ?><p class="notice error" role="alert"><?= escaped($answer['error']) ?></p><?php endif; ?>
<?php if ($errors): ?><p class="notice error" role="alert">Review the marked fields. Your other entries are still here.</p><?php endif; ?>
<form method="post" action="/"><input type="hidden" name="csrf" value="<?= escaped($csrf) ?>">
<div class="field"><label for="name">Name</label><input id="name" name="name" autocomplete="name" maxlength="100" value="<?= escaped($values['name']) ?>" required<?= isset($errors['name']) ? ' aria-invalid="true" aria-describedby="name-error"' : '' ?>><?php if (isset($errors['name'])): ?><p class="error" id="name-error"><?= escaped($errors['name']) ?></p><?php endif; ?></div>
<div class="field">
<!-- example:form -->
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" maxlength="254"
       value="<?= htmlspecialchars($values['email'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
       aria-invalid="<?= isset($errors['email']) ? 'true' : 'false' ?>"
       aria-describedby="email-error" required>
<!-- /example:form -->
<p class="error" id="email-error"><?= escaped($errors['email'] ?? '') ?></p></div>
<?php if ($answer['suggestion']): ?><p class="suggestion">Did you mean <?= escaped($answer['suggestion']) ?>? Edit the email field if you want to use it.</p><?php endif; ?>
<div class="row"><div class="field"><label for="phone">Phone (optional)</label><input id="phone" name="phone" type="tel" autocomplete="tel" maxlength="100" value="<?= escaped($values['phone']) ?>"<?= isset($errors['phone']) ? ' aria-invalid="true" aria-describedby="phone-error"' : '' ?>><?php if (isset($errors['phone'])): ?><p class="error" id="phone-error"><?= escaped($errors['phone']) ?></p><?php endif; ?></div>
<div class="field"><label for="country">Phone country</label><select id="country" name="country"<?= isset($errors['country']) ? ' aria-invalid="true" aria-describedby="country-error"' : '' ?>><?php foreach (COUNTRIES as $code => $name): ?><option value="<?= $code ?>"<?= $values['country'] === $code ? ' selected' : '' ?>><?= $name ?></option><?php endforeach; ?></select><?php if (isset($errors['country'])): ?><p class="error" id="country-error"><?= escaped($errors['country']) ?></p><?php endif; ?></div></div>
<div class="field"><label for="message">Message</label><textarea id="message" name="message" maxlength="2000" dir="auto" required<?= isset($errors['message']) ? ' aria-invalid="true" aria-describedby="message-error"' : '' ?>><?= escaped($values['message']) ?></textarea><?php if (isset($errors['message'])): ?><p class="error" id="message-error"><?= escaped($errors['message']) ?></p><?php endif; ?></div><button type="submit">Send message</button></form></main></body></html>
<?php
}

function runContact(): void
{
    header('Content-Type: text/html; charset=utf-8');
    header('Cache-Control: no-store');
    header('X-Content-Type-Options: nosniff');
    if (!preg_match('/^(?:127\.0\.0\.1|localhost)(?::[0-9]{1,5})?$/iD', $_SERVER['HTTP_HOST'] ?? '')) {
        http_response_code(400);
        echo '<h1>Open this app on localhost.</h1>';
        return;
    }
    session_name('parse_contact');
    session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);
    session_start();
    $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
    $values = ['name' => '', 'email' => '', 'phone' => '', 'country' => '', 'message' => ''];
    $answer = ['errors' => [], 'error' => null, 'suggestion' => null, 'status' => 200];
    $accepted = ($_SESSION['contact_accepted'] ?? false) === true;
    unset($_SESSION['contact_accepted']);
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        foreach ($values as $field => $_value) $values[$field] = is_string($_POST[$field] ?? null) ? $_POST[$field] : '';
        $token = $_POST['csrf'] ?? null;
        if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 16384) {
            $answer['error'] = 'This message is too large. Use at most 2,000 characters.';
            $answer['status'] = 413;
        } elseif (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
            $answer['error'] = 'This form expired. Refresh the page and try again.';
            $answer['status'] = 403;
        } else {
            $key = trim(getenv('PARSEAPI_KEY') ?: '');
            if ($key === '' || str_starts_with($key, 'parse_public_') || str_starts_with($key, 'parse_app_')) {
                $answer['error'] = 'Your details could not be checked. Nothing was sent. Please try again later.';
                $answer['status'] = 503;
            } else {
                // example:client
                $parse = new \ParseAPI\Client(timeout: 10.0, retries: 0);
                // /example:client
                $send = static function (string $to, string $body, array $headers): bool {
                    // example:delivery
                    $accepted = mail($to, 'Website contact request', $body, $headers);
                    // /example:delivery
                    return $accepted;
                };
                try {
                    $answer = submitContact($values, $parse, $send,
                        ['to' => getenv('CONTACT_TO') ?: '', 'from' => getenv('CONTACT_FROM') ?: '']);
                } finally {
                    $parse->close();
                }
                if ($answer['accepted']) {
                    $_SESSION['csrf'] = bin2hex(random_bytes(32));
                    $_SESSION['contact_accepted'] = true;
                    header('Location: /', true, 303);
                    return;
                }
            }
        }
    }
    http_response_code($answer['status']);
    renderContact($values, $answer, $_SESSION['csrf'], $accepted);
}

if (PHP_SAPI !== 'cli') runContact();
Try the complete example on your computer

After completing the setup above, save the source in your application folder and start the app:

Terminal
php -S 127.0.0.1:8000 php-contact-form.php

Open 127.0.0.1:8000 to use the form. The server keeps your secret key out of the page.

For every field and parameter, see Email API reference and Phone API reference.

Build something else

All tutorials →