Project: Contact Form with Email

JA JASWANT Updated 04 Nov 2026
8 min read ·Lesson 234 of 238

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

FeatureTechniqueLesson
Validation with field-specific errors and sticky valuesServer-side validation, filter_var()PHP Form Validation, Sticky Forms
Protection against forged submissionsCSRF tokenCSRF Protection
Spam reductionHoneypot field and rate limitingPHP Sessions
Safe email headersSymfony Mailer with fixed From, visitor in Reply-ToSending Email
No duplicate submissions on refreshPost/Redirect/Get with a flash messageHTTP Headers and Redirects
Safe outputhtmlspecialchars() everywhereXSS 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

  1. Each value is read safely: arrays sent instead of strings (name[]=x) become empty strings instead of causing errors.
  2. 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.
  3. The subject must be one of the allowed keys; the email subject line is built from our own labels, never from free text.
  4. 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

  • From is 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 into Reply-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

TestExpected Result
Submit an empty formErrors under each field; nothing is sent
Enter asha@example as email"Please enter a valid email address." with the other fields kept
Submit valid dataRedirect to the page with the thank-you message; email arrives in Mailpit
Refresh after successNo 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 nameShown as text in the form; arrives as plain text in the email
Use an invalid MAILER_DSNFriendly 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else User-defined Functions Slicing Strings Function Arguments PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro PHP Class Constants Encryption with Sodium Recursive Functions Variable Functions Anonymous Functions Closures Arrow Functions First-class Callable Syntax PHP Arrays Indexed Arrays Associative Arrays Create Arrays Variables Scope Static Variables Variable Variables PHP Echo / Print PHP printf / sprintf PHP Data Types PHP Booleans PHP NULL PHP Strings String Functions array_map / array_filter / array_reduce Array Functions PHP Superglobals $GLOBALS What Is `$_SERVER` ? $_REQUEST $_POST $_GET $_FILES $_COOKIE PHP Type Juggling MySQL Order By MySQL Delete Data MySQL Update Data MySQL Limit Data MySQL Joins with PHP Pagination with PHP PDO Introduction PDO Connect PDO Prepared Statements PDO Fetch Modes PSR-4 Autoloading Installing Extensions (PECL / PIE) OPcache & Performance PHP-FPM Project: Contact Form with Email Project: Login & Registration System Project: CRUD App with MySQL Project: Shopping Cart with Sessions Project: REST API with PHP
Ask about this post
AI Ask about this post

Ask questions about Project: Contact Form with Email and get answers drawn from it.

Signed-in readers only.