PHP Project — Build a Secure Contact Form from Scratch
In this tutorial, you will learn about PHP Project. We cover key concepts, practical examples, and best practices to help you master this topic.
This PHP project walks through building a complete secure contact form that validates user input, protects against CSRF Attacks, stores messages in a database, sends email notifications, and filters spam using server-side checks and a simple honeypot technique. You will apply everything from previous lessons: PHP 8 features, PDO database access, security practices, and Composer packages.
What You'll Learn
- Building a complete PHP contact form from requirements to deployment
- Implementing server-side validation with filter_var and custom rules
- Protecting against CSRF with token generation and session-based verification
- Storing form submissions in MySQL via PDO prepared statements
- Sending formatted email notifications using PHP's mail() function
- Adding spam protection with honeypot fields and Rate Limiting
- Displaying user-friendly success and error messages
Why It Matters
The contact form is the most common interactive element on any website. It also happens to be the most attacked: spammers abuse it, bots submit garbage, and attackers probe it for injection vulnerabilities. Building a secure contact form teaches you fundamental web security patterns that apply to every form you will ever build.
Real-World Use
DodaTech's own support contact form processes 500+ submissions daily. It uses the exact patterns shown here: CSRF tokens, honeypot fields, rate limiting, and email notifications. The same codebase powers feedback forms for Doda Browser and Durga Antivirus Pro.
Learning Path
flowchart LR A[PHP 8 Features] --> B[Contact Form\nYou are here] B --> C[Blog Project] style B fill:#f90,color:#fff
Project Structure
contact-form/
index.php -- Form display and processing
config.php -- Database and email configuration
functions.php -- Validation, CSRF, and storage functions
style.css -- Basic form styling
templates/
form.php -- HTML form template
thank-you.php -- Success page template
error.php -- Error display template
Step 1: Configuration and Database Setup
Create config.php:
<?php
define('DB_HOST', 'localhost');
define('DB_NAME', 'contact_form');
define('DB_USER', 'root');
define('DB_PASS', '');
define('ADMIN_EMAIL', 'admin@example.com');
define('SITE_NAME', 'DodaTech');
define('RATE_LIMIT_SECONDS', 60);
function getDbConnection(): PDO
{
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
Create the database table:
CREATE TABLE contact_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(200) NOT NULL,
message TEXT NOT NULL,
ip_address VARCHAR(45) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_read TINYINT(1) DEFAULT 0
);
Step 2: Validation and CSRF Functions
Create functions.php:
<?php
function startSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function generateCsrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function validateCsrfToken(string $token): bool
{
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
function validateFormData(array $data): array
{
$errors = [];
$name = trim($data['name'] ?? '');
if (strlen($name) < 2 || strlen($name) > 100) {
$errors[] = 'Name must be between 2 and 100 characters';
}
if (preg_match('/[<>\'"]/', $name)) {
$errors[] = 'Name contains invalid characters';
}
$email = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
$errors[] = 'Please provide a valid email address';
}
$subject = trim($data['subject'] ?? '');
if (strlen($subject) < 3 || strlen($subject) > 200) {
$errors[] = 'Subject must be between 3 and 200 characters';
}
$message = trim($data['message'] ?? '');
if (strlen($message) < 10 || strlen($message) > 5000) {
$errors[] = 'Message must be between 10 and 5000 characters';
}
$honeypot = $data['website'] ?? '';
if (!empty($honeypot)) {
$errors[] = 'Bot detected';
}
return [
'errors' => $errors,
'data' => [
'name' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'),
'email' => $email,
'subject' => htmlspecialchars($subject, ENT_QUOTES, 'UTF-8'),
'message' => htmlspecialchars($message, ENT_QUOTES, 'UTF-8'),
],
];
}
function checkRateLimit(string $ip): bool
{
$pdo = getDbConnection();
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM contact_messages WHERE ip_address = :ip AND created_at > NOW() - INTERVAL :seconds SECOND'
);
$stmt->execute(['ip' => $ip, 'seconds' => RATE_LIMIT_SECONDS]);
return $stmt->fetchColumn() === 0;
}
function saveMessage(array $data, string $ip): bool
{
$pdo = getDbConnection();
$stmt = $pdo->prepare(
'INSERT INTO contact_messages (name, email, subject, message, ip_address) VALUES (:name, :email, :subject, :message, :ip)'
);
return $stmt->execute([
'name' => $data['name'],
'email' => $data['email'],
'subject' => $data['subject'],
'message' => $data['message'],
'ip' => $ip,
]);
}
function sendNotification(array $data): bool
{
$to = ADMIN_EMAIL;
$subject = "New Contact Form: {$data['subject']}";
$headers = "From: noreply@" . $_SERVER['HTTP_HOST'] . "\r\n";
$headers .= "Reply-To: {$data['email']}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$body = "Name: {$data['name']}\n";
$body .= "Email: {$data['email']}\n";
$body .= "Subject: {$data['subject']}\n";
$body .= "Message:\n{$data['message']}\n";
return mail($to, $subject, $body, $headers);
}
function getValidationError(string $field): string
{
return $_SESSION['form_errors'][$field] ?? '';
}
function setValidationErrors(array $errors): void
{
$_SESSION['form_errors'] = $errors;
}
Step 3: Main Application Logic
Create index.php:
<?php
require_once 'config.php';
require_once 'functions.php';
startSession();
$csrfToken = generateCsrfToken();
$success = false;
$errors = [];
$oldInput = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? '';
if (!validateCsrfToken($token)) {
$errors[] = 'Invalid form submission. Please try again.';
} else {
$result = validateFormData($_POST);
if (!empty($result['errors'])) {
$errors = $result['errors'];
$oldInput = $_POST;
} elseif (!checkRateLimit($_SERVER['REMOTE_ADDR'])) {
$errors[] = 'You are submitting too quickly. Please wait ' . RATE_LIMIT_SECONDS . ' seconds.';
} else {
saveMessage($result['data'], $_SERVER['REMOTE_ADDR']);
sendNotification($result['data']);
$success = true;
$oldInput = [];
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
setValidationErrors($errors);
}
if ($success) {
require 'templates/thank-you.php';
} else {
require 'templates/form.php';
}
Step 4: Form Template
Create templates/form.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Us - <?= SITE_NAME ?></title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Contact Us</h1>
<p>Fill out the form below and we will get back to you within 24 hours.</p>
<?php if (!empty($errors)): ?>
<div class="alert alert-error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="index.php" novalidate>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<div style="position:absolute;left:-9999px">
<label for="website">Website</label>
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off">
</div>
<div class="form-group">
<label for="name">Name *</label>
<input type="text" name="name" id="name"
value="<?= htmlspecialchars($oldInput['name'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
required minlength="2" maxlength="100">
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" name="email" id="email"
value="<?= htmlspecialchars($oldInput['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
required>
</div>
<div class="form-group">
<label for="subject">Subject *</label>
<input type="text" name="subject" id="subject"
value="<?= htmlspecialchars($oldInput['subject'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
required minlength="3" maxlength="200">
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea name="message" id="message" rows="6"
required minlength="10" maxlength="5000"><?= htmlspecialchars($oldInput['message'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
</div>
<button type="submit" class="btn">Send Message</button>
</form>
</div>
</body>
</html>
Step 5: Success and Error Templates
Create templates/thank-you.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank You - <?= SITE_NAME ?></title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Thank You!</h1>
<p>Your message has been sent successfully. We will respond to your inquiry within 24 hours.</p>
<p><a href="index.php" class="btn">Send Another Message</a></p>
</div>
</body>
</html>
Testing the Form
Start the PHP built-in server and test:
php -S localhost:8000
Submit the form with valid data. Check the database:
mysql -e "SELECT name, email, subject FROM contact_form.contact_messages;"
Output:
+-------+-------------------+---------+
| name | email | subject |
+-------+-------------------+---------+
| Alice | alice@example.com | Inquiry |
+-------+-------------------+---------+
Common Mistakes
- Not regenerating CSRF token after submission: The same token is valid for the entire session. Regenerate it after successful submission to prevent replay attacks.
- Trusting the honeypot field alone: Bots can detect hidden fields. Combine honeypot with rate limiting and time-based checks for stronger spam protection.
- Showing detailed error messages to users: Error messages like "Duplicate entry for key" reveal database structure. Show generic errors and log specifics.
- Not sanitizing output: Always use htmlspecialchars when rendering user data. A stored XSS in the admin message viewer could compromise the admin account.
- Skipping email validation: Without email validation, invalid addresses cause mail delivery failures. Use filter_var with FILTER_VALIDATE_EMAIL.
Practice Questions
Why use a honeypot field instead of CAPTCHA?
- A honeypot is invisible to humans but visible to bots that auto-fill all fields. It provides spam protection without user friction.
How does CSRF token validation prevent attacks?
- The attacker cannot read the victim's session data, so they cannot include a valid token in a forged form submission.
What is the purpose of rate limiting?
- Rate limiting prevents a single IP from flooding the database or email system with submissions in a short time.
Why is the name validated against HTML characters?
- To prevent XSS if the admin dashboard displays contact submissions without proper escaping.
Challenge: Add a JavaScript-free CAPTCHA that asks a simple math question (e.g., "What is 3 + 4?"). Validate the answer server-side and discard submissions with incorrect answers.
Mini Project
Enhance the contact form with additional features:
- Add a file upload field for attachments (max 5MB, allow only PDF and images).
- Store attachments outside the web root with hashed filenames.
- Add an admin dashboard that lists all submissions with read/unread status.
- Add a reply feature: admin replies appear as email threads.
- Implement search and pagination for the admin dashboard.
FAQ
What's Next
Now build a more complex application: a Blog Project with categories, tags, and comments.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro