PHP Contact Form Tutorial: Bootstrap Design + Secure Email with PHPMailer

Author

Kritim Yantra

Sep 15, 2025

PHP Contact Form Tutorial: Bootstrap Design + Secure Email with PHPMailer

What we’re building (features)

  • ✅ Clean Bootstrap form (name, email, subject, message)
  • ✅ Server-side validation (required, format, length)
  • ✅ XSS-safe output with htmlspecialchars()
  • ✅ CSRF protection (session token)
  • ✅ Honeypot field (invisible to humans; catches bots)
  • ✅ Basic rate-limit (per session)
  • ✅ Sends email via Gmail SMTP with PHPMailer
  • ✅ Production-safe config via .env + vlucas/phpdotenv
  • ✅ No error details leaked to users (logged instead)

1) Project structure

secure-contact/
├─ public/
│  ├─ index.php           # The form
│  ├─ process.php         # Handles POST + sends email
│  ├─ assets/
│  │  └─ css/bootstrap.min.css
├─ vendor/                # Composer deps (auto-created)
├─ .env                   # Secrets (NEVER commit)
├─ .env.example
├─ composer.json
├─ bootstrap.php          # Loads autoloader, Dotenv, sessions, security helpers
└─ .htaccess              # Deny access to .env (if using Apache)

Put .env and bootstrap.php outside public/ so the web server can’t serve them directly.


2) Install dependencies

You’ll need PHP 8+ and Composer.

cd secure-contact
composer init -n
composer require phpmailer/phpmailer vlucas/phpdotenv

3) Add environment variables

Create .env in the project root (same folder as composer.json):

# Mail settings
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=your_gmail_address@gmail.com
MAIL_PASSWORD=your_app_password_here
MAIL_FROM=your_gmail_address@gmail.com
MAIL_FROM_NAME="Website Contact"
MAIL_TO=destination@example.com

# App
APP_ENV=production
APP_DEBUG=false

Gmail SMTP tips

  • Turn on 2-Step Verification for your Google account.
  • Create an App Password (16-char) and paste it as MAIL_PASSWORD.
  • Gmail disabled “Less secure apps”; App Passwords are the correct way now.

Add a shareable sample file for teammates:

.env.example

MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=example@gmail.com
MAIL_PASSWORD=app_password_here
MAIL_FROM=example@gmail.com
MAIL_FROM_NAME="Website Contact"
MAIL_TO=destination@example.com

APP_ENV=local
APP_DEBUG=true

4) Load Dotenv, start sessions, set safe defaults

bootstrap.php

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Dotenv\Dotenv;

$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad(); // don't error if .env missing

// Basic PHP production-safe defaults (override via php.ini in real deployments)
ini_set('display_errors', $_ENV['APP_DEBUG'] === 'true' ? '1' : '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/php_errors.log');

// Start secure session early
if (session_status() !== PHP_SESSION_ACTIVE) {
    session_start([
        'cookie_httponly' => true,
        'cookie_secure'   => isset($_SERVER['HTTPS']),
        'cookie_samesite' => 'Lax',
    ]);
}

// CSRF token helper
function csrf_token(): string {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function verify_csrf(?string $token): bool {
    return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], (string)$token);
}

// Output encoding helper (prevents XSS when echoing user input)
function e(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// Basic rate limiting per session (N posts every M minutes)
function rate_limited(int $max = 3, int $minutes = 5): bool {
    $now = time();
    $_SESSION['submits'] = array_filter($_SESSION['submits'] ?? [], fn($t) => $t > $now - $minutes * 60);
    if (count($_SESSION['submits']) >= $max) {
        return true;
    }
    $_SESSION['submits'][] = $now;
    return false;
}

// Very small email header injection guard
function sanitize_email_header(string $email): string {
    // strip CRLF to avoid injecting headers
    return preg_replace("/[\r\n]+/", '', $email);
}

5) Protect your secrets (Apache)

public/.htaccess (optional but recommended if using Apache):

# Deny access to sensitive files
<FilesMatch "^(\.env|composer\.json|composer\.lock|php_errors\.log)$">
    Require all denied
</FilesMatch>

6) The Bootstrap contact form (with CSRF + honeypot)

public/index.php

<?php require __DIR__ . '/../bootstrap.php'; ?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contact Us</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <link href="assets/css/bootstrap.min.css" rel="stylesheet">
  <style>
    /* Honeypot: hidden from humans, visible to bots */
    .hp-field { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
  </style>
</head>
<body class="bg-light">
<div class="container py-5">
  <div class="row justify-content-center">
    <div class="col-lg-7">
      <div class="card shadow-sm">
        <div class="card-body p-4">
          <h1 class="h3 mb-3">Contact Us</h1>

          <?php if (!empty($_SESSION['flash'])): ?>
            <div class="alert alert-<?= e($_SESSION['flash']['type']) ?>"><?= e($_SESSION['flash']['message']) ?></div>
            <?php unset($_SESSION['flash']); ?>
          <?php endif; ?>

          <form action="process.php" method="post" novalidate>
            <input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">

            <!-- Honeypot (should stay empty) -->
            <div class="hp-field">
              <label>Leave this field empty</label>
              <input type="text" name="website" autocomplete="off">
            </div>

            <div class="mb-3">
              <label class="form-label">Your Name</label>
              <input type="text" class="form-control" name="name" maxlength="100" required>
              <div class="form-text">Max 100 characters.</div>
            </div>

            <div class="mb-3">
              <label class="form-label">Email address</label>
              <input type="email" class="form-control" name="email" maxlength="160" required>
            </div>

            <div class="mb-3">
              <label class="form-label">Subject</label>
              <input type="text" class="form-control" name="subject" maxlength="150" required>
            </div>

            <div class="mb-3">
              <label class="form-label">Message</label>
              <textarea class="form-control" name="message" rows="6" maxlength="5000" required></textarea>
              <div class="form-text">Up to 5000 characters.</div>
            </div>

            <button class="btn btn-primary w-100" type="submit">Send Message</button>
          </form>

          <p class="small text-muted mt-3">
            We’ll never show detailed errors here. If something goes wrong, we log it and let you know gracefully. 🙂
          </p>
        </div>
      </div>
    </div>
  </div>
</div>
</body>
</html>

Why this is safe:

  • No user data is echoed without e(...).
  • CSRF token is per-session.
  • Honeypot traps basic bots.
  • maxlength prevents oversized payloads; server will re-validate too.

7) Handle POST securely + send via PHPMailer (Gmail)

public/process.php

<?php
declare(strict_types=1);

require __DIR__ . '/../bootstrap.php';

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

function fail(string $message, string $type = 'danger'): void {
    $_SESSION['flash'] = ['type' => $type, 'message' => $message];
    header('Location: index.php');
    exit;
}

// 1) Basic request checks
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    fail('Invalid request method.');
}

if (!verify_csrf($_POST['csrf_token'] ?? '')) {
    fail('Security token mismatch. Please try again.');
}

// 2) Honeypot check
if (!empty($_POST['website'])) {
    // bot likely
    fail('Submission blocked.');
}

// 3) Rate limit
if (rate_limited(max: 3, minutes: 5)) {
    fail('Too many messages. Please wait a few minutes and try again.');
}

// 4) Collect and validate input
$name    = trim((string)($_POST['name'] ?? ''));
$email   = trim((string)($_POST['email'] ?? ''));
$subject = trim((string)($_POST['subject'] ?? ''));
$message = trim((string)($_POST['message'] ?? ''));

$errors = [];

// Required + length checks
if ($name === '' || mb_strlen($name) > 100)        $errors[] = 'Please provide a valid name (max 100 chars).';
if ($subject === '' || mb_strlen($subject) > 150)  $errors[] = 'Please provide a valid subject (max 150 chars).';
if ($message === '' || mb_strlen($message) > 5000) $errors[] = 'Message is required (max 5000 chars).';

if ($email === '' || mb_strlen($email) > 160 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = 'Please provide a valid email address.';
}

if ($errors) {
    fail(implode(' ', $errors));
}

// Guard against email header injection
$email = sanitize_email_header($email);

// 5) Build a safe, simple plaintext + HTML body
$plainText = "New contact form submission:\n"
    . "Name: {$name}\n"
    . "Email: {$email}\n"
    . "Subject: {$subject}\n\n"
    . "Message:\n{$message}\n";

$htmlBody = '<h2>New contact form submission</h2>'
    . '<p><strong>Name:</strong> ' . e($name) . '</p>'
    . '<p><strong>Email:</strong> ' . e($email) . '</p>'
    . '<p><strong>Subject:</strong> ' . e($subject) . '</p>'
    . '<p><strong>Message:</strong><br>' . nl2br(e($message)) . '</p>';

try {
    // 6) Configure PHPMailer
    $mail = new PHPMailer(true);
    $mail->isSMTP();
    $mail->Host       = $_ENV['MAIL_HOST'] ?? 'smtp.gmail.com';
    $mail->SMTPAuth   = true;
    $mail->Username   = $_ENV['MAIL_USERNAME'] ?? '';
    $mail->Password   = $_ENV['MAIL_PASSWORD'] ?? '';
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // TLS on 587
    $mail->Port       = (int)($_ENV['MAIL_PORT'] ?? 587);

    // Avoid verbose SMTP debug in production
    $mail->SMTPDebug  = 0;

    // Gmail sometimes requires strong ciphers; leave defaults unless needed:
    // $mail->SMTPOptions = ['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false]];

    // 7) From / To
    $fromEmail = $_ENV['MAIL_FROM'] ?? $_ENV['MAIL_USERNAME'] ?? '';
    $fromName  = $_ENV['MAIL_FROM_NAME'] ?? 'Website Contact';

    $mail->setFrom($fromEmail, $fromName);
    $mail->addAddress((string)($_ENV['MAIL_TO'] ?? $fromEmail)); // fallback to self
    $mail->addReplyTo($email, $name); // replying goes to the sender

    // 8) Content
    $mail->Subject = "[Contact] " . $subject;
    $mail->isHTML(true);
    $mail->Body    = $htmlBody;
    $mail->AltBody = $plainText;

    // 9) Send
    $mail->send();

    // Success
    fail('Thank you! Your message has been sent. ✅', 'success');
} catch (Exception $e) {
    // Log internal error for admins only
    error_log('Mail error: ' . $e->getMessage());
    fail('Sorry, we could not send your message right now. Please try again later.');
}

Security notes (what we did & why):

  • Server-side validation is mandatory (client-side can be bypassed).
  • htmlspecialchars everywhere before rendering back => prevents XSS.
  • CSRF token blocks cross-site form posts.
  • Honeypot blocks simple bots without Captcha friction.
  • Header injection guard strips CRLF from the email header inputs.
  • No stack traces to users; detailed errors go to error_log.

8) Optional: Client-side UX sugar (keeps server rules!)

You can add HTML5 validation or small JS hints, but never rely on it alone. The server rules above remain the source of truth.


9) Testing checklist 🧪

  • Submit valid data → get success flash.
  • Submit empty/too long → get friendly error.
  • Tamper with csrf_tokenblocked.
  • Fill honeypot → blocked.
  • Submit >3 times in 5 minutes → rate-limited.
  • Try <script>alert(1)</script> in message → email should show the tags as text, not execute.
  • Check server logs for any PHPMailer errors if email doesn’t arrive.

10) Deploying safely

  • Put public/ as your web root. Keep vendor/, .env, and bootstrap.php outside public.
  • Set proper file permissions; don’t allow world-writable on code.
  • Use HTTPS (TLS). Cookies are flagged Secure automatically if HTTPS is detected.
  • Rotate App Passwords if team members change.

Quick FAQ

Q1: I get “Could not authenticate” when sending mail.
Make sure you use App Password (not your normal Gmail password), and MAIL_USERNAME equals your Gmail address.

Q2: Can I add reCAPTCHA?
Yes—add Google reCAPTCHA v2/3 on the form and verify in process.php before sending. Keep CSRF + honeypot too.

Q3: Can I send to multiple recipients?
Yes: $mail->addAddress('a@b.com'); $mail->addAddress('c@d.com');


Copy-paste summary (what you need to edit)

  • In .env: set MAIL_USERNAME, MAIL_PASSWORD (App Password), MAIL_TO.
  • In index.php: customize labels/branding.
  • In process.php: adjust rate limit numbers if needed.

Tags

Comments

No comments yet. Be the first to comment!

Please log in to post a comment:

Sign in with Google

Related Posts