PHP Contact Form Handler

A contact form is almost standard on every website, yet many first versions just forward the content through mail() — no input validation, no spam protection, and no defense against forged senders. By the time the inbox is full of junk, fixing it is expensive. Below is a more robust PHP approach that covers input validation, CSRF protection, rate limiting, and a honeypot field.

A Basic Working Version

<?php
// contact.php — basic contact form handler
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name = htmlspecialchars($_POST['name'] ?? '');
    $email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
    $message = htmlspecialchars($_POST['message'] ?? '');

    if (!$email) {
        die('Please enter a valid email address.');
    }

    $to = '[email protected]';
    $subject = "Inquiry from $name";
    $headers = "From: $email\r\nReply-To: $email";

    if (mail($to, $subject, $message, $headers)) {
        echo 'Thank you for your message!';
    } else {
        echo 'Sending failed, please try again later.';
    }
}
?>

This version works, but three things are missing: no captcha or rate limit, using the visitor's email as the sender is likely to be flagged as spam, and there is no CSRF protection.

Adding Spam and Security Layers

Measure Purpose
CSRF token Prevent cross-site forged submissions
Session rate limit Max 3 submissions per session per hour
Honeypot field Hidden input; bots that fill it are discarded
Header sanitization Fixed From address prevents header injection

For CSRF, start a session, generate a one-time token into a hidden field, and compare it on submit. For rate limiting, record the submission time and count in the session. A honeypot field is invisible to humans but easily filled by automated scripts. Stacking these three makes most automated spam drop off.

In the real world, spam submissions are more aggressive than most people expect. I once saw a small business site that collected over four thousand junk messages in three months after its second week online — mostly "add my WeChat" and "loan" promotions that were impossible to keep up with manually. After adding a honeypot field and session rate limiting, the junk dropped to nearly zero almost immediately. Protection is not optional; it is baseline work you should do before launch.

The honeypot trick is simple: put an input box hidden with CSS in the form, give it an arbitrary name (say website), which real users never see or fill, while automated scripts happily populate it. On submit, if that field has a value, treat the request as a bot — either silently return success or a 403, but never signal "submission failed", or the bot will keep trying with different scripts.

A More Complete Handler

In production, split the logic: parse the input (supporting both regular POST and AJAX JSON), validate the CSRF token and field lengths, then send through your mail service.

<?php
// contact.php — secure handler
session_start();
if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

$input = ($_SERVER['CONTENT_TYPE'] === 'application/json')
    ? json_decode(file_get_contents('php://input'), true)
    : $_POST;

// CSRF check
if (!isset($input['csrf_token']) || $input['csrf_token'] !== $_SESSION['csrf_token']) {
    http_response_code(403);
    exit('Session expired. Please refresh the page.');
}

// Field validation
$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
if (!$email) {
    http_response_code(422);
    exit('Please enter a valid email address.');
}

// Honeypot: a filled hidden field means a bot
if (!empty($input['website'])) {
    http_response_code(200);
    exit('Thank you!'); // silent reject
}

// Fixed From address prevents forged senders
$headers = "From: Site Contact Form <[email protected]>\r\nReply-To: $email\r\n";
if (mail('[email protected]', 'New site message', $input['message'] ?? '', $headers)) {
    echo 'Thank you! We will get back to you soon.';
} else {
    http_response_code(500);
    echo 'Sending failed, please try again later.';
}
?>

Comparing Three Sending Channels

Channel Deliverability Maintenance Best for
mail() Low, often flagged as spam Lowest Local development, temporary tests
SMTP (business mailbox / mail provider) High with SPF/DKIM Medium Production environments
Third-party form service / email API High, anti-abuse built in Low (pay per use) Fast launch without server upkeep

Which channel you pick is a trade-off between deliverability and maintenance. mail() relies on the server's built-in MTA, and many cloud hosts' default setups are already blacklisted by major mailbox providers, so messages usually land in spam. SMTP means configuring SPF and DKIM records in your mail provider's dashboard — a small one-time learning cost, then it just works. If you prefer to skip those details, a third-party form service (like Formspree or Basin) or an email API (like Resend) is easier, at the cost of per-use fees and data passing through a third party.

Field Validation and Length Limits

Beyond email format, constrain field length and content too. A practical set of boundaries:

Field Suggested limit Why
Name 2-50 characters Shorter is often random; longer is meaningless
Email 3-254 characters Follows the email address length spec
Message 10-2000 characters Too short is empty; too long may be flooding or attacks
URL (honeypot) Must be empty Non-empty means a bot

Length checks are a second layer on top of filter_var using mb_strlen(), which counts characters rather than bytes and handles multibyte text correctly. Error responses should also distinguish cases: 422 for field errors, 403 for an expired session, 500 for server failure — the frontend can then show different messages.

Migrating from mail() to SMTP

If your site still uses mail() and messages keep going missing, a migration to SMTP typically takes an afternoon: grab the SMTP host, port, and credentials from your mail provider or business mailbox; add SPF and DKIM records to DNS; then swap the sending code from mail() to PHPMailer or Symfony Mailer's SmtpTransport. After migrating, send a test message to each of QQ Mail, 163 Mail, and Gmail and confirm they land in the inbox rather than spam. The details are covered in the SMTP configuration guide.

Scenario Recommendations

For a personal blog's simple message box, the version above is enough. For a company site or one that feeds a customer-service system, persist submissions to a database, add backend notifications, and consider a third-party form service or a transactional email API. Field validation and AJAX forms, SMTP configuration to avoid the spam folder, deliverability optimization, and automated email workflows are covered in related guides.

Frequently Asked Questions

  • mail() fails or lands in spam? The built-in mail function is unreliable; switch to SMTP and configure SPF/DKIM first.
  • Why can't I trust user input directly? Header injection, XSS, and forged senders are real risks — always filter input and escape output.
  • Can I return JSON instead of HTML for AJAX? Yes — standardize success and failure as {success: true, message: "..."}, which is simpler for the frontend to parse. See the contact form AJAX guide for the interaction details.
  • When do I need a captcha? Honeypot plus rate limiting already stops most bots; only add an image captcha or Cloudflare Turnstile if spam is still heavy, rather than slowing down real users from the start.

References

Reference: PHP filter_var documentation https://www.php.net/manual/en/function.filter-var.php
Reference: PHP mail function https://www.php.net/manual/en/function.mail.php
Reference: PHP sessions https://www.php.net/manual/en/book.session.php
Reference: PHPMailer https://github.com/PHPMailer/PHPMailer