Santaji GadePHP, DevelopmentYesterday5 Views

Build a php contact form with spam protection combining a honeypot field, IP rate limiting, server side validation, a CSRF token, and reCAPTCHA v3.
Table of Contents
ToggleA contact form with no defenses fills an inbox with junk within days of going live, long before a real customer ever finds it. A php contact form with spam protection stays useful because the bot traffic never reaches the inbox at all.
A plain HTML form with a PHP handler behind it works the moment it ships. It also has no idea whether the request came from a person or a script.
Automated bots crawl the web looking for exactly this pattern, an open form action pointing at a PHP file with no checks in front of it. Cloudflare's overview of bot traffic gives a good sense of just how much of the web this kind of scanning actually covers.
Within a short time, a completely unprotected form starts receiving dozens of submissions a day that are not from real visitors at all.
Each layer added below blocks a different kind of automated traffic, and together they cover almost everything a small site actually sees.
The form itself stays simple. Every protection layer in this guide lives in the PHP file that processes the submission, not in extra form fields the visitor has to fill out.
<form action="submit.php" method="POST">
<input type="text" name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<input type="text" name="website" class="bx-hidden-field" autocomplete="off" tabindex="-1">
<button type="submit">Send</button>
</form>
That extra website field is not a mistake. It is the honeypot the next section relies on, and it needs to already exist in the markup.
A honeypot is a form field real visitors never see or fill in, but that most automated bots fill in anyway since they do not render the page.
if (!empty($_POST['website'])) {
// a filled honeypot means a bot, so exit quietly with no error
http_response_code(200);
exit;
}
Hiding the field with CSS instead of type="hidden" matters, since some bots specifically skip inputs with a hidden type but still fill in a text field they can see in the raw markup. Setting tabindex to a negative value also keeps the field out of the normal keyboard navigation order for real visitors.
.bx-hidden-field {
position: absolute;
left: -9999px;
}
A honeypot stops simple bots, but a more determined script can still hammer the form with dozens of submissions a minute from the same address.
function is_rate_limited($ip, $max_per_hour = 5) {
$file = sys_get_temp_dir() . '/rate_' . md5($ip) . '.json';
$now = time();
$hits = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
$hits = array_filter($hits, fn($t) => $now - $t < 3600);
if (count($hits) >= $max_per_hour) {
return true;
}
$hits[] = $now;
file_put_contents($file, json_encode($hits));
return false;
}
A five submission per hour limit is generous for a real visitor filling out a contact form once, but tight enough to stop a script from flooding the inbox. PHPMailer itself, available through Packagist and installed with Composer, still handles the actual sending once a message clears every check in this guide.
Client side validation is easy to bypass, since a bot skips the browser entirely and sends a POST request directly to the PHP file.
$name = trim(filter_input(INPUT_POST, 'name', FILTER_SANITIZE_SPECIAL_CHARS));
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$message = trim(filter_input(INPUT_POST, 'message', FILTER_SANITIZE_SPECIAL_CHARS));
if (!$name || !$email || !$message) {
exit('Please fill in every field with a valid value.');
}
FILTER_VALIDATE_EMAIL catches most malformed addresses on its own, and rejecting the submission outright when it fails keeps obviously fake entries out of the message.
| Layer | Stops | Visitor Friction |
|---|---|---|
| Honeypot field | Simple form scraping bots | None, invisible to real visitors |
| Rate limiting by IP | Scripted flooding attacks | None for normal one time submissions |
| Server side validation | Malformed or fake data | None, just standard field rules |
| reCAPTCHA v3 | Sophisticated bots and scrapers | Very low, runs silently in the background |
A cross site request forgery token confirms the submission actually came from the form on your own site, not from a request forged on another page. OWASP's CSRF prevention guide covers the reasoning behind this in more depth than a single contact form needs, but it is worth reading once.
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// echo this into a hidden input named csrf_token when rendering the form
if ($_POST['csrf_token' ?? ''] !== $_SESSION['csrf_token']) {
http_response_code(403);
exit('Invalid request.');
}
random_bytes() generates a cryptographically strong token, which is a meaningfully different guarantee than a predictable value like a timestamp would give.
The layers above stop most automated traffic. reCAPTCHA v3 catches the more sophisticated bots that get past everything else, and it runs invisibly in the background. Registering a site key takes a few minutes through the reCAPTCHA admin console.
function verify_recaptcha($token, $secret) {
$response = file_get_contents(
'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret . '&response=' . $token
);
$result = json_decode($response, true);
return ($result['success'] ?? false) && ($result['score'] ?? 0) >= 0.5;
}
The score returned ranges from 0 to 1, and a threshold around 0.5 is a reasonable starting point that most sites can tune later based on real traffic.
Once a submission has passed every check above, sending it uses the same PHPMailer pattern this series has covered for writing send email code in PHP, an SMTP call wrapped in a try and catch block.
if (!is_rate_limited($_SERVER['REMOTE_ADDR']) && verify_recaptcha($_POST['g_recaptcha_response'], $secret)) {
send_notification_email('contact@brandella.in', 'New contact form message', $message);
}
Every layer runs before this final step, so by the time send_notification_email() executes, the message has already survived four separate checks. This is the complete shape of a php contact form with spam protection, four layers working together instead of one control doing all the work.
Traversy Media's PHP series walks through a working contact form handler, which pairs well with the honeypot and validation layers added in this guide.
Video credit: Traversy Media.
The base handler shown in the video is a solid starting point once the honeypot, rate limiting, and reCAPTCHA layers from this guide sit in front of it.
It stops simple bots but not every one. Pairing it with rate limiting and reCAPTCHA covers the more determined scripts a honeypot alone will miss.
No. The rate limiting example in this guide uses a temporary file per IP address, which is enough for a small to medium site without any database setup.
No. Version 3 runs entirely in the background and returns a score, unlike the older checkbox based version 2 that visitors had to click.
A five submission per hour limit rarely affects a genuine visitor, since nobody normally submits the same contact form that many times in one session.
Yes. Without it, another site could submit forged requests to your form handler using a visitor's own browser session without their knowledge.
No single check stops every kind of automated traffic on its own.
Real visitors never notice it, but simple bots fill it in anyway.
A temporary file per IP address is enough for most small sites.
Client side checks alone are trivial for a bot to bypass entirely.
They prove a submission came from your own form, not a forged request.
It catches the sophisticated bots everything else lets through.
Pair a php contact form with spam protection alongside a wider toolkit to keep every inbound message legitimate.









