Level: beginner to intermediate. You built a basic contact form in Building a Contact Form. Now you will make it more realistic: messages go to MySQL, with CSRF protection, a spam trap and a rate limit, and an admin can read them.
The table
CREATE TABLE contacts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(190) NOT NULL,
message TEXT NOT NULL,
ip_hash CHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The IP address is stored as a hash, so you can count messages per visitor without keeping the raw address.
The service
<?php
declare(strict_types=1);
final class ContactService
{
public function __construct(private PDO $pdo, private int $maxPerHour = 3) {}
/** @return array<string, string> field => error */
public function validate(array $input): array
{
$errors = [];
$name = trim((string) ($input["name"] ?? ""));
$email = trim((string) ($input["email"] ?? ""));
$message = trim((string) ($input["message"] ?? ""));
if (mb_strlen($name) < 2 || mb_strlen($name) > 100) {
$errors["name"] = "Please enter your name (2 to 100 characters).";
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false || mb_strlen($email) > 190) {
$errors["email"] = "Please enter a valid email address.";
}
if (mb_strlen($message) < 10 || mb_strlen($message) > 2000) {
$errors["message"] = "Your message needs 10 to 2000 characters.";
}
return $errors;
}
public function isRateLimited(string $ip): bool
{
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM contacts WHERE ip_hash = :ip AND created_at >= :since");
$stmt->execute(["ip" => $this->hashIp($ip), "since" => date("Y-m-d H:i:s", time() - 3600)]);
return (int) $stmt->fetchColumn() >= $this->maxPerHour;
}
public function save(array $input, string $ip): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO contacts (name, email, message, ip_hash, created_at) VALUES (:name, :email, :message, :ip, :created)"
);
$stmt->execute([
"name" => trim((string) $input["name"]),
"email" => strtolower(trim((string) $input["email"])),
"message" => trim((string) $input["message"]),
"ip" => $this->hashIp($ip),
"created" => date("Y-m-d H:i:s"),
]);
return (int) $this->pdo->lastInsertId();
}
public function latest(int $limit = 20): array
{
$stmt = $this->pdo->prepare("SELECT id, name, email, message FROM contacts ORDER BY id DESC LIMIT :limit");
$stmt->bindValue(":limit", $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
private function hashIp(string $ip): string
{
return hash_hmac("sha256", $ip, getenv("APP_KEY") ?: "change-this-secret");
}
}
hash_hmac() with a secret key (from the environment) means the stored value cannot be reversed, even though there are only about four billion IPv4 addresses.
Test the whole flow
<?php
require __DIR__ . "/ContactService.php";
$service = new ContactService(require __DIR__ . "/db.php", maxPerHour: 2);
$input = ["name" => "Isha", "email" => "isha@example.com", "message" => "Hello, I have a question about PHP."];
echo count($service->validate($input)) . " errors\n";
print_r($service->validate(["name" => "I", "email" => "nope", "message" => "short"]));
foreach (range(1, 3) as $try) {
if ($service->isRateLimited("203.0.113.9")) {
echo "attempt $try: rate limited\n";
continue;
}
echo "attempt $try: saved as #" . $service->save($input, "203.0.113.9") . "\n";
}
echo count($service->latest()) . " messages stored";
0 errors
Array
(
[name] => Please enter your name (2 to 100 characters).
[email] => Please enter a valid email address.
[message] => Your message needs 10 to 2000 characters.
)
attempt 1: saved as #1
attempt 2: saved as #2
attempt 3: rate limited
2 messages stored
The third attempt from the same address within the hour was blocked.
The test assumes the class is saved as ContactService.php and that db.php returns your PDO connection.
The page
<?php
declare(strict_types=1);
session_start();
require __DIR__ . "/functions.php"; // h(), csrfToken(), csrfCheck()
require __DIR__ . "/ContactService.php";
$service = new ContactService(require __DIR__ . "/db.php");
$old = ["name" => "", "email" => "", "message" => ""];
$errors = [];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
csrfCheck();
// honeypot: a field humans never see. Bots fill it.
if (($_POST["website"] ?? "") !== "") {
header("Location: contact.php?sent=1"); // pretend it worked
exit;
}
$old = [
"name" => trim((string) ($_POST["name"] ?? "")),
"email" => trim((string) ($_POST["email"] ?? "")),
"message" => trim((string) ($_POST["message"] ?? "")),
];
$errors = $service->validate($old);
$ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0";
if ($errors === [] && $service->isRateLimited($ip)) {
$errors["form"] = "You have sent several messages already. Please try again later.";
}
if ($errors === []) {
$service->save($old, $ip);
$_SESSION["flash"] = "Thank you! Your message has been received.";
header("Location: contact.php");
exit;
}
}
$flash = $_SESSION["flash"] ?? null;
unset($_SESSION["flash"]);
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Contact us</title></head>
<body>
<h1>Contact us</h1>
<?php if ($flash): ?><p role="status"><?= h($flash) ?></p><?php endif; ?>
<?php if (isset($errors["form"])): ?><p role="alert"><?= h($errors["form"]) ?></p><?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<!-- Honeypot: hidden from people with CSS and from screen readers -->
<div style="position:absolute; left:-9999px;" aria-hidden="true">
<label for="website">Leave this empty</label>
<input id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<p>
<label for="name">Name</label><br>
<input id="name" name="name" value="<?= h($old["name"]) ?>" required>
<?php if (isset($errors["name"])): ?><br><span role="alert"><?= h($errors["name"]) ?></span><?php endif; ?>
</p>
<p>
<label for="email">Email</label><br>
<input id="email" type="email" name="email" value="<?= h($old["email"]) ?>" required>
<?php if (isset($errors["email"])): ?><br><span role="alert"><?= h($errors["email"]) ?></span><?php endif; ?>
</p>
<p>
<label for="message">Message</label><br>
<textarea id="message" name="message" rows="6" required><?= h($old["message"]) ?></textarea>
<?php if (isset($errors["message"])): ?><br><span role="alert"><?= h($errors["message"]) ?></span><?php endif; ?>
</p>
<button type="submit">Send</button>
</form>
</body>
</html>
The admin list
<?php
declare(strict_types=1);
session_start();
require dirname(__DIR__) . "/functions.php";
require dirname(__DIR__) . "/ContactService.php";
if (($_SESSION["role"] ?? "") !== "admin") { // use the login system from the authentication chapter
http_response_code(403);
exit("Forbidden");
}
$service = new ContactService(require dirname(__DIR__) . "/db.php");
?>
<h1>Messages</h1>
<?php foreach ($service->latest(50) as $m): ?>
<article>
<h2><?= h($m["name"]) ?> <<?= h($m["email"]) ?>></h2>
<p><?= nl2br(h($m["message"])) ?></p>
</article>
<?php endforeach; ?>
nl2br(h(...)) escapes first, then turns new lines into <br>. The order matters.
What you practised
- A service that holds the rules, with a database dependency passed in.
- Prepared statements and a hashed IP address.
- CSRF tokens, a honeypot and a rate limit as layers of spam defence.
- Post/Redirect/Get with a flash message.
- Escaping for HTML and for line breaks.
Challenges
- Send an email notification to the site owner with PHPMailer (installed with Composer).
- Add a “mark as read” column and an admin button (POST with a token).
- Add pagination to the admin list (Pagination Basics).
- Add a Cloudflare Turnstile or reCAPTCHA check for stronger bot protection (you will need keys from that service, so keep them in environment variables).