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
ParseAPISet up ParseAPI
Install the SDK in your application and set PARSEAPI_KEY in your server environment.
composer require parseapi/sdk:1.12.0
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'Create a client and reuse it for the calls below:
require __DIR__ . '/vendor/autoload.php';
$parse = new \ParseAPI\Client();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:
<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:
$parse = new \ParseAPI\Client(timeout: 10.0, retries: 0);Call Email with the submitted address:
$email = $parse->email(trim($values['email']));Read the parsed address and validation facts:
$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.
$phone = $parse->phone(trim($values['phone']), $phoneCountry);The returned international number can go into the message body:
$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:
$headers = ['From' => $delivery['from'], 'Reply-To' => $replyTo,
'Content-Type' => 'text/plain; charset=UTF-8'];The complete example uses PHP's existing mail transport:
$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
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:
php -S 127.0.0.1:8000 php-contact-form.phpOpen 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
- Check an email as someone types
- Add an email delivery check to your app
- Build a contact review step for inbound emails
- Validate a phone number in your form