Project: Contact Form with Email
What You Will Build
A production-quality contact form for a website: visitors enter their name, email, a subject, and a message; the site validates the input, sends a plain-text email to the support team, and shows a confirmation. The project combines lessons on forms, validation, sessions, CSRF protection, escaping, and sending email.
Features
| Feature | Technique | Lesson |
|---|---|---|
| Validation with field-specific errors and sticky values | Server-side validation, filter_var() | PHP Form Validation, Sticky Forms |
| Protection against forged submissions | CSRF token | CSRF Protection |
| Spam reduction | Honeypot field and rate limiting | PHP Sessions |
| Safe email headers | Symfony Mailer with fixed From, visitor in Reply-To | Sending Email |
| No duplicate submissions on refresh | Post/Redirect/Get with a flash message | HTTP Headers and Redirects |
| Safe output | htmlspecialchars() everywhere | XSS Prevention |
Project Structure
contact-form/
├── composer.json
├── .env ← MAILER_DSN (not committed)
├── public/
│ └── index.php ← the page: form display and submission handling
└── src/
├── bootstrap.php ← autoloader, session, helpers
└── contact.php ← validation and email sending
Setup
mkdir contact-form && cd contact-form
composer require symfony/mailer
The mail transport is configured through an environment variable, for example:
MAILER_DSN=smtp://contact%40gocourse.in:app-password@smtp.example.com:587
During development, a local mail catcher such as Mailpit (MAILER_DSN=smtp://localhost:1025) receives all emails, so nothing is sent to real addresses.
Step 1: Bootstrap and Helpers
<?php
// src/bootstrap.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => !empty($_SERVER['HTTPS']),
'use_strict_mode' => true,
]);
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrfToken(): string
{
return $_SESSION['_csrf'] ??= bin2hex(random_bytes(32));
}
function csrfValid(mixed $submitted): bool
{
$expected = $_SESSION['_csrf'] ?? '';
return is_string($submitted) && $expected !== '' && hash_equals($expected, $submitted);
}
function flash(?string $message = null): ?string
{
if ($message !== null) {
$_SESSION['_flash'] = $message;
return null;
}
$stored = $_SESSION['_flash'] ?? null;
unset($_SESSION['_flash']);
return $stored;
}
These helpers are reused in every project of this section: escaping (e()), CSRF tokens, and one-time flash messages that survive a redirect.
Step 2: Validation
<?php
// src/contact.php
const CONTACT_SUBJECTS = [
'general' => 'General question',
'course' => 'Course enquiry',
'internship' => 'Internship program',
];
function validateContact(array $input): array
{
$clean = fn(string $key): string => is_string($input[$key] ?? null) ? trim($input[$key]) : '';
$data = [
'name' => preg_replace('/\s+/u', ' ', $clean('name')),
'email' => strtolower($clean('email')),
'subject' => $clean('subject'),
'message' => str_replace("\r\n", "\n", $clean('message')),
];
$errors = [];
$nameLength = mb_strlen($data['name']);
if ($nameLength < 2 || $nameLength > 80) {
$errors['name'] = 'Please enter your name (2–80 characters).';
}
if (filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Please enter a valid email address.';
}
if (!array_key_exists($data['subject'], CONTACT_SUBJECTS)) {
$errors['subject'] = 'Please choose a subject.';
}
$messageLength = mb_strlen($data['message']);
if ($messageLength < 10) {
$errors['message'] = 'Please write at least 10 characters.';
} elseif ($messageLength > 2000) {
$errors['message'] = 'Please keep your message under 2000 characters.';
}
return ['data' => $data, 'errors' => $errors];
}
Testing the Validation
<?php
require __DIR__ . '/src/contact.php';
$invalid = validateContact(['name' => ' Asha ', 'email' => 'asha@example', 'subject' => 'course', 'message' => 'Hi']);
print_r($invalid['errors']);
$valid = validateContact([
'name' => " Asha \n Kumar ",
'email' => 'Asha@Example.com',
'subject' => 'course',
'message' => 'I would like details about the PHP course.',
]);
echo json_encode($valid) . "\n";
Output
Array
(
[email] => Please enter a valid email address.
[message] => Please write at least 10 characters.
)
{"data":{"name":"Asha Kumar","email":"asha@example.com","subject":"course","message":"I would like details about the PHP course."},"errors":[]}
Why It Is Written This Way
- Each value is read safely: arrays sent instead of strings (
name[]=x) become empty strings instead of causing errors. - Whitespace in the name — including line breaks — is collapsed to single spaces. Names later appear in email headers (
Reply-To), where line breaks could otherwise be abused for header injection. - The subject must be one of the allowed keys; the email subject line is built from our own labels, never from free text.
- The function returns the normalized data and the errors, so the form can show errors and keep the values. It has no side effects and can be tested without a browser.
Step 3: Sending the Email
<?php
// src/contact.php (continued)
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
function sendContactEmail(array $data): void
{
$mailer = new Mailer(Transport::fromDsn((string) getenv('MAILER_DSN')));
$body = "New message from the website contact form\n\n"
. "Name: {$data['name']}\n"
. "Email: {$data['email']}\n"
. "Subject: " . CONTACT_SUBJECTS[$data['subject']] . "\n\n"
. $data['message'] . "\n";
$email = (new Email())
->from(new Address('no-reply@gocourse.in', 'GoCourse Website'))
->to('support@gocourse.in')
->replyTo(new Address($data['email'], $data['name']))
->subject('Contact form: ' . CONTACT_SUBJECTS[$data['subject']])
->text($body);
$mailer->send($email);
}
Key Decisions
Fromis always your own address. Sending "from" the visitor's address would fail email authentication (SPF, DKIM, DMARC) and land in spam. The visitor's address goes intoReply-To, so pressing "Reply" answers them directly.- Plain text only. The message is never interpreted as HTML, so nothing in it can inject markup into the support team's mail client.
- Symfony Mailer builds headers safely and supports SMTP, API-based providers, and local catchers through the DSN. PHP's
mail()function also works but requires a configured server mail system and careful header handling (see Sending Email).
Step 4: The Page
<?php
// public/index.php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/contact.php';
$data = ['name' => '', 'email' => '', 'subject' => '', 'message' => ''];
$errors = [];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!csrfValid($_POST['_csrf'] ?? null)) {
$errors['form'] = 'Your session expired. Please submit the form again.';
} elseif (($_POST['website'] ?? '') !== '') {
flash('Thank you! Your message has been sent.'); // honeypot filled: silently ignore the bot
header('Location: /', true, 303);
exit;
} elseif (time() - ($_SESSION['last_contact_at'] ?? 0) < 60) {
$errors['form'] = 'Please wait a minute before sending another message.';
} else {
['data' => $data, 'errors' => $errors] = validateContact($_POST);
if ($errors === []) {
try {
sendContactEmail($data);
$_SESSION['last_contact_at'] = time();
flash('Thank you! Your message has been sent. We usually reply within one working day.');
header('Location: /', true, 303);
exit;
} catch (Throwable $exception) {
error_log('Contact form email failed: ' . $exception->getMessage());
$errors['form'] = 'Sorry, your message could not be sent. Please try again later.';
}
}
}
}
$success = flash();
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Contact Us</title></head>
<body>
<h1>Contact Us</h1>
<?php if ($success !== null): ?>
<p class="success" role="status"><?= e($success) ?></p>
<?php endif; ?>
<?php if (isset($errors['form'])): ?>
<p class="error" role="alert"><?= e($errors['form']) ?></p>
<?php endif; ?>
<form method="post" action="/" novalidate>
<input type="hidden" name="_csrf" value="<?= e(csrfToken()) ?>">
<div class="hp" aria-hidden="true">
<label>Leave this field empty <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<label>Name
<input name="name" value="<?= e($data['name']) ?>" required maxlength="80">
</label>
<?php if (isset($errors['name'])): ?><p class="error"><?= e($errors['name']) ?></p><?php endif; ?>
<label>Email
<input type="email" name="email" value="<?= e($data['email']) ?>" required>
</label>
<?php if (isset($errors['email'])): ?><p class="error"><?= e($errors['email']) ?></p><?php endif; ?>
<label>Subject
<select name="subject" required>
<option value="">Choose…</option>
<?php foreach (CONTACT_SUBJECTS as $key => $label): ?>
<option value="<?= e($key) ?>" <?= $data['subject'] === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (isset($errors['subject'])): ?><p class="error"><?= e($errors['subject']) ?></p><?php endif; ?>
<label>Message
<textarea name="message" rows="6" required maxlength="2000"><?= e($data['message']) ?></textarea>
</label>
<?php if (isset($errors['message'])): ?><p class="error"><?= e($errors['message']) ?></p><?php endif; ?>
<button type="submit">Send message</button>
</form>
</body>
</html>
The honeypot div is hidden with CSS (.hp { position: absolute; left: -9999px; }), so people never see it, while simple spam bots fill in every field.
How a Submission Flows
POST / ─► CSRF valid? ── no ─► show "session expired"
│ yes
▼
honeypot empty? ── no ─► pretend success (redirect), send nothing
│ yes
▼
rate limit OK? ── no ─► show "please wait"
│ yes
▼
valid input? ── no ─► show form with errors and previous values
│ yes
▼
send email ── fails ─► log details, show friendly error
│ success
▼
flash message + 303 redirect ─► GET / shows "Thank you!"
Testing the Project
| Test | Expected Result |
|---|---|
| Submit an empty form | Errors under each field; nothing is sent |
Enter asha@example as email | "Please enter a valid email address." with the other fields kept |
| Submit valid data | Redirect to the page with the thank-you message; email arrives in Mailpit |
| Refresh after success | No second email (Post/Redirect/Get) |
| Send twice within a minute | "Please wait a minute…" |
| Remove the CSRF field in developer tools and submit | "Your session expired…" |
Enter <script>alert(1)</script> as the name | Shown as text in the form; arrives as plain text in the email |
Use an invalid MAILER_DSN | Friendly error on the page; details in the error log |
Common Mistakes
Using the Visitor's Address as From
Effect: Emails fail SPF/DKIM checks and are rejected or marked as spam. Fix: Send from your domain and put the visitor in Reply-To.
Building Headers From Raw Input
Effect: Header injection lets attackers add recipients. Fix: Use a mail library and strip line breaks from values used in headers.
Not Redirecting After Success
Effect: Refreshing the page sends the email again. Fix: Use Post/Redirect/Get with a 303 redirect.
Showing Mailer Exceptions to Visitors
Effect: Server names and credentials may be exposed. Fix: Log the details; show a generic message.
Best Practices
- Keep validation in a pure, tested function that returns data and errors.
- Protect the form with CSRF tokens, a honeypot, and rate limiting.
- Send plain-text email through a mail library with a fixed sender and
Reply-To. - Use Post/Redirect/Get with flash messages, and escape every value in the page.
- Use a local mail catcher in development and an authenticated mail service in production. Official reference: https://www.php.net/manual/en/function.mail.php