Blog / Coding tips

PHP Contact Form With mail(), No PHPMailer Needed

Cover image for PHP Contact Form With mail(), No PHPMailer Needed

To send a contact form email with PHP's built-in mail() function, validate the visitor's input, remove line breaks from anything that goes into a mail header, send from an address on your own domain, put the visitor's address in Reply-To, pass your address with -f as the fifth argument, and check what mail() returns. That's the whole recipe. No Composer, no PHPMailer, no SMTP password stored in your code. Below is the version I wrote for the contact form on my portfolio, step by step, with a way to test it without sending a real email.

I tested every snippet on PHP 8.4 with php-cli, and I ran the full form on PHP's built-in server with a fake sendmail so I could read the exact email PHP produced.

When mail() is enough (and when it isn't)

mail() hands your message to the server's local mail program (usually sendmail or a compatible one). On most Linux shared hosting that program is already set up, so mail() works with zero configuration. For a contact form that sends a plain-text notification to you, that's all you need.

It's the wrong tool when you need HTML newsletters, attachments, bulk sending, or delivery reports. It also won't help if your host disables mail() or your messages keep landing in spam no matter what you fix. In those cases, use SMTP through a library like PHPMailer or Symfony Mailer, or a transactional email service.

Step 1: a safe mail function

I keep the sending code in its own file so I can test it from the command line:

<?php
declare(strict_types=1);

const MAIL_TO   = 'you@example.com';      // your inbox, never taken from the form
const MAIL_FROM = 'no-reply@example.com'; // a mailbox on YOUR domain

/** Replace characters that could start a new mail header. */
function header_safe(string $value): string
{
    return trim(str_replace(["\r", "\n", "\0"], ' ', $value));
}

/** Encode a header value so Bangla, emoji etc. survive (RFC 2047). */
function encode_header(string $value): string
{
    return '=?UTF-8?B?' . base64_encode($value) . '?=';
}

function send_contact_mail(string $name, string $email, string $subject, string $message): bool
{
    $replyTo = filter_var(header_safe($email), FILTER_VALIDATE_EMAIL);
    if ($replyTo === false) {
        return false; // not one clean address, so refuse to send
    }
    $name    = header_safe($name);
    $subject = header_safe($subject);

    $body = "Name: {$name}\nEmail: {$replyTo}\n\n"
          . str_replace("\r\n", "\n", $message) . "\n";

    $headers = [
        'From'                      => 'Website contact form <' . MAIL_FROM . '>',
        'Reply-To'                  => $replyTo,
        'MIME-Version'              => '1.0',
        'Content-Type'              => 'text/plain; charset=UTF-8',
        'Content-Transfer-Encoding' => '8bit',
    ];

    // The 5th argument sets the envelope sender (Return-Path). Many hosts
    // need it for SPF to pass. Only ever put your own constant here.
    return mail(MAIL_TO, encode_header('Contact: ' . $subject), $body, $headers, '-f' . MAIL_FROM);
}

Each choice in that function is there for a reason:

  • The recipient is a constant. Never take the "to" address from the form, or anyone can use your server to send email to anyone.
  • From is your own domain. Old tutorials put the visitor's email in From. That's spoofing: Gmail and other providers check SPF and DMARC, see that your server isn't allowed to send for gmail.com, and send the message to spam or reject it. The visitor's address goes in Reply-To, so pressing Reply in your inbox still answers them.
  • header_safe() blocks header injection. Mail headers are separated by line breaks. If a bot sends an "email" like x@example.com\r\nBcc: victim@example.org and you paste it into a header, it has just added its own Bcc line. Replacing \r, \n and \0 stops that, and FILTER_VALIDATE_EMAIL then rejects whatever is left.
  • Headers as an array. Since PHP 7.2, mail() accepts an array of headers and joins them correctly, so you don't have to glue strings together with "\r\n".
  • An encoded subject. Mail headers are meant to be plain ASCII. The =?UTF-8?B?...?= format (RFC 2047) lets a Bangla subject like "ওয়েবসাইট project" arrive intact.
  • The -f parameter sets the envelope sender, the address bounces go to. Many shared hosts need it to match your domain for SPF to pass. Only ever put your own constant there, never user input, because this argument goes to the command line of the mail program.

Step 2: test it without sending a real email

PHP runs whatever program the sendmail_path setting names, so for testing I pointed it at a tiny shell script that saves the message to a file instead of sending it:

#!/bin/sh
# fake-sendmail.sh: saves the arguments and the raw message
{ echo "ARGS: $*"; cat; } > /tmp/post01-mail.eml

Then I called the function with a normal message and a malicious one, running php -d sendmail_path=./fake-sendmail.sh test_mailer.php:

<?php
// Run: php -d sendmail_path=./fake-sendmail.sh test_mailer.php
require __DIR__ . '/mailer.php';

$ok = send_contact_mail('Mahir Faysal', 'mahir@example.com', 'ওয়েবসাইট project', "Hi!\r\nCan we talk?");
var_dump($ok);
echo file_get_contents('/tmp/post01-mail.eml');

// A bot tries to add a Bcc header through the email field
$evil = "x@example.com\r\nBcc: victim@example.org";
var_dump(send_contact_mail('Bot', $evil, 'hello', 'spam'));

// A newline in the name or subject just becomes a space
echo header_safe("Rafi\r\nBcc: victim@example.org"), "\n";

// Output:
// bool(true)
// ARGS: -fno-reply@example.com
// To: you@example.com
// Subject: =?UTF-8?B?Q29udGFjdDog4KaT4Kav4Ka84KeH4Kas4Ka44Ka+4KaH4KafIHByb2plY3Q=?=
// From: Website contact form <no-reply@example.com>
// Reply-To: mahir@example.com
// MIME-Version: 1.0
// Content-Type: text/plain; charset=UTF-8
// Content-Transfer-Encoding: 8bit
//
// Name: Mahir Faysal
// Email: mahir@example.com
//
// Hi!
// Can we talk?
//
// bool(false)
// Rafi  Bcc: victim@example.org

The real message has the right headers, the Bangla subject is encoded, and Windows line endings in the message are normalized. The injection attempt returns false and nothing is sent. This trick is also handy for checking your headers before you deploy.

Step 3: the contact form page

Here is a complete, single-file form that uses send_contact_mail(). The validation is kept short on purpose. For the full version with length limits, allow-lists and a CSRF token, see my PHP form validation and sanitization guide.

<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/mailer.php';

function e(string $v): string
{
    return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$errors = [];
$old = ['name' => '', 'email' => '', 'subject' => '', 'message' => ''];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    foreach ($old as $key => $_) {
        $old[$key] = is_string($_POST[$key] ?? null) ? trim($_POST[$key]) : '';
    }
    $honeypot = trim((string) ($_POST['website'] ?? ''));

    if ($old['name'] === '')    $errors['name'] = 'Please enter your name.';
    if (filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false)
                                $errors['email'] = 'Please enter a valid email.';
    if ($old['subject'] === '') $errors['subject'] = 'Please add a subject.';
    if (strlen($old['message']) < 10) $errors['message'] = 'Please write a little more.';

    if ($errors === []) {
        // Bots fill the hidden field: pretend it worked, send nothing.
        $sent = $honeypot !== '' || send_contact_mail($old['name'], $old['email'], $old['subject'], $old['message']);
        if ($sent) {
            $_SESSION['flash'] = 'Thanks! Your message was sent.';
            header('Location: ' . $_SERVER['PHP_SELF'], true, 303);
            exit;
        }
        $errors['form'] = 'Sorry, the message could not be sent. Please try again later.';
    }
}
$flash = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Contact</title></head>
<body>
<?php if ($flash !== ''): ?><p><?= e($flash) ?></p><?php endif; ?>
<?php foreach ($errors as $msg): ?><p><?= e($msg) ?></p><?php endforeach; ?>
<form method="post">
  <label>Name <input name="name" type="text" value="<?= e($old['name']) ?>" required></label>
  <label>Email <input name="email" type="email" value="<?= e($old['email']) ?>" required></label>
  <label>Subject <input name="subject" type="text" value="<?= e($old['subject']) ?>" required></label>
  <label>Message <textarea name="message" required><?= e($old['message']) ?></textarea></label>
  <!-- Honeypot: hidden from people, filled in by many bots -->
  <div hidden><label>Website <input name="website" type="text" tabindex="-1" autocomplete="off"></label></div>
  <button type="submit">Send</button>
</form>
</body>
</html>

What the page does:

  • A honeypot field. The website input is hidden from people, but many bots fill in every field. When it has a value, the page pretends the message was sent and quietly drops it, so the bot learns nothing.
  • Post/Redirect/Get. After a successful send, a 303 redirect loads the page again with GET, so refreshing doesn't send a second email. The thank-you message travels in the session.
  • It checks the return value. If mail() returns false, the visitor sees an error instead of a fake success. I tested that by pointing sendmail_path at /bin/false.
  • Escaped sticky values. When validation fails, the form keeps what the visitor typed, escaped with htmlspecialchars(). Sending <b>Rafi" as the name came back as harmless text.

On my own site, the message is also saved to MySQL before the email goes out, so nothing is lost if the email fails. If you want to do the same, use PDO prepared statements for the insert.

Why the email doesn't arrive

mail() returning true only means the local mail program accepted the message. It doesn't mean it was delivered. When nothing shows up, check these in order:

  1. Your spam folder. Then mark the message as "not spam" once.
  2. The From address. It must be on the domain the site runs on, like no-reply@yourdomain.com. Some hosts require the mailbox to really exist in the control panel.
  3. SPF and DKIM. Your domain's DNS should have an SPF record that includes your host's mail servers. Most hosting panels have a one-click setting for SPF and DKIM.
  4. Your local machine. XAMPP on Windows has no mail server, so you'll see a warning like "Failed to connect to mailserver at localhost port 25". Test with the fake sendmail trick, then test real delivery on the live host.
  5. The host's mail logs or limits. Shared hosts often cap emails per hour, and some disable mail() entirely. Their support can tell you which.

FAQ

Is PHP's mail() function deprecated?

No. It's still part of PHP 8.4. It's simply low-level: it doesn't do SMTP authentication, HTML templates or attachments for you.

Do I need PHPMailer for a contact form?

Not if your host supports mail() and you're sending plain text to yourself. Switch to PHPMailer with SMTP when you need authentication, attachments, or better deliverability than your host's mail server gives you.

Should I send an automatic reply to the visitor?

Be careful. Anyone can type someone else's address into your form, so an auto-reply lets bots use your server to send email to strangers. If you add one, keep its text fixed and never include the visitor's message in it.

Is a honeypot enough to stop spam?

It stops lazy bots. For smarter ones, add a simple question that a person answers easily. I wrote about that in my simple math captcha in PHP post.

Conclusion

A safe PHP contact form with mail() fits in two small files: a fixed recipient, a From address on your own domain, the visitor in Reply-To, newlines stripped from header values, an encoded subject and the -f envelope sender. Check the return value, redirect after success, and test with a fake sendmail before you go live. The PHP manual page for mail() lists every parameter if you want to go further.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

Gemini Structured Output With Zod in Next.js
Coding tips

Gemini Structured Output With Zod in Next.js

Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.

October 2, 2026 · 9 min read

How to Add a Gemini API Key to Next.js and Vercel
Coding tips

How to Add a Gemini API Key to Next.js and Vercel

Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.

October 2, 2026 · 8 min read

Build an Offline Habit Tracker With localStorage
Coding tips

Build an Offline Habit Tracker With localStorage

A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.

October 2, 2026 · 10 min read