Time: an evening, most of it spent waiting for DNS. Cost: nothing on top of the hosting you already have. Difficulty: you need to be able to upload a file to your web host and paste some SQL into its database tool.

At the end of this you will have a contact form on your own website that does four things properly:

Why a form at all, rather than just an email address? Because it's already there, in the browser, on the site the person has just been reading. Filling in an enquiry form is a lot easier than drafting an email to someone you don't know, so a form gets you the enquiries an email link would have lost.

There's no framework involved. It's one PHP file, one config file and one database table, and it runs on practically any web host.

The form on this site's contact page is built this way. Since I finished it on 23 August it has turned away 164 robots, and every one of them is a row in a table with the reason next to it. The most recent arrived at 08:21 this morning, offering me a 2026 Rolls-Royce Phantom for $15,000.

Measured on 1 October 2026. The live figures come from the azorius.com database, PHP 8.2 under Apache on a Plesk VPS. I wrote the code below this evening and ran it against a throwaway database on Nexus, a Raspberry Pi running PHP 8.2.33 and MariaDB, forcing every path through it with stand-ins for Google and SendGrid. Then I submitted a real enquiry through the live form in a real browser, and the row it created is quoted below.

Before you start

What you need to buy: nothing. Google's reCAPTCHA and the main email providers all have free allowances, and a contact form sits comfortably inside them. Check the current terms when you sign up, because they change more often than the code does.

What you need to have:

What you need to decide:

The build

Six steps.

1. Make the table, and a database user that can only touch it

In your host's database tool (phpMyAdmin, Plesk, cPanel or the mysql command line), create a database. Then create a user just for the form, and give it permission to add and update rows in that one database and nothing else:

CREATE DATABASE mysite;
CREATE USER 'mysite'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT SELECT, INSERT, UPDATE ON mysite.* TO 'mysite'@'localhost';

Most control panels have a button that does exactly this. The point is that the form's password can't delete anything, and can't see your other databases.

Then the table. Every column is there for a reason, and the four at the bottom are the ones most contact forms leave out:

CREATE TABLE contact_submissions (
  id              INT AUTO_INCREMENT PRIMARY KEY,
  name            VARCHAR(255) NOT NULL,
  email           VARCHAR(255) NOT NULL,
  subject         VARCHAR(255) NULL,
  message         TEXT NOT NULL,
  outcome         ENUM('accepted','rejected') NOT NULL,
  reject_reason   VARCHAR(32) NULL,          -- why the gate said no
  recaptcha_score DECIMAL(3,2) NULL,         -- how human the visitor looked, 0.00 to 1.00
  emailed         TINYINT(1) NULL,           -- did the notification actually go? NULL = never tried
  is_test         TINYINT(1) NOT NULL DEFAULT 0,
  ip_address      VARCHAR(45) NULL,
  created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) DEFAULT CHARSET=utf8mb4;

What you should see: an empty table called contact_submissions.

2. Get your spam-check keys

Go to the reCAPTCHA admin console (search for "reCAPTCHA admin"; it's at google.com/recaptcha/admin) and register your site.

You'll get two keys. The site key is public and goes in your page. The secret key is private and stays on your server.

One trap to know about now: Google publishes a pair of "test keys" that always pass. They're v2 keys, so they return a pass with no score at all. The code below rejects that as no_score, and it's right to. If you see that reason while testing, you're using the wrong kind of key, not looking at a bug.

3. Set up the email provider, and prove you own your domain

Sign up with your provider and find its domain authentication page. In SendGrid it's under Settings, Sender Authentication. It will give you three or four DNS records, usually CNAMEs, to add at your domain's DNS host. These records are what let your form's emails say they're from hello@yourdomain.com and be believed.

Add them, wait (this is the part of the evening spent waiting), and press the provider's "verify" button until it goes green.

Then create an API key with permission to send mail and nothing else. If that key ever leaks, the worst anyone can do with it is send email as you, which is bad but survivable. A full-access key would let them into the whole account.

4. Put the secrets in a file the web can't reach

Your host serves files from a folder usually called public_html, httpdocs or www. Put this file one level above that folder, so that no web address can ever reach it:

<?php
// Lives OUTSIDE the web root, so no URL can ever reach it. Never commit the real values.
return [
    'db_dsn'      => 'mysql:host=localhost;dbname=mysite;charset=utf8mb4',
    'db_user'     => 'mysite',
    'db_pass'     => 'change-me',

    'recaptcha_site_key' => 'your-site-key',     // public: it goes in the page
    'recaptcha_secret'   => 'your-secret-key',   // private: server only
    'min_score'          => 0.5,                 // below this, reject

    'sendgrid_key' => 'SG.your-api-key',
    'mail_from'    => 'hello@example.com',       // must be on your authenticated domain
    'mail_to'      => 'hello@example.com',       // where enquiries go

    // Only change these to point at a stub while testing.
    'verify_url'   => 'https://www.google.com/recaptcha/api/siteverify',
    'sendgrid_url' => 'https://api.sendgrid.com/v3/mail/send',
];

So the layout on the server is:

config.php            <- secrets: not reachable from the web
public_html/
  contact.php         <- the form: reachable at yoursite.com/contact.php

This matters more than it looks. Until tonight, this site kept its keys as text inside PHP files in the web folder. They were run, never served, so they were never exposed, but they're in a private file above the web folder now, the same as this. A file inside the web folder is one mistake away from being readable by anyone. The usual mistake is a backup copy someone left behind called contact.php.bak, which the server will often hand over as plain text instead of running it. A file outside the web folder can't be served at all, whatever it's called.

5. The form

Save this as contact.php in your web folder. It's the whole thing: the page, the spam check, the database and the email.

<?php
// A contact form that stores every submission, emails you the real ones, and writes down what it refused.
$config = require __DIR__ . '/../config.php';      // one level up: outside the web root
$db = new PDO($config['db_dsn'], $config['db_user'], $config['db_pass'],
              [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

function e(?string $s): string { return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8'); }

function post_json(string $url, array $headers, string $body): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
        CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10]);
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$status, $response];
}

// 1. VERIFY. Every path ends in a decision, and "no proof" is a no.
function check_human(array $config, string $token): array {      // returns [reason or null, score]
    if ($token === '') return ['no_token', null];                 // bots never run the page's script
    [$status, $body] = post_json($config['verify_url'], [],
        http_build_query(['secret' => $config['recaptcha_secret'], 'response' => $token]));
    if ($status !== 200) return ['verifier_unreachable', null];
    $result = json_decode($body, true) ?: [];
    if (empty($result['success'])) return ['verify_failed', null];
    if (!isset($result['score'])) return ['no_score', null];     // a v2 key, or Google's test key
    if (($result['action'] ?? '') !== 'contact') return ['wrong_action', $result['score']];
    if ($result['score'] < $config['min_score']) return ['low_score', $result['score']];
    return [null, $result['score']];
}

// 3. NOTIFY. Returns true only if the provider accepted the message.
function send_email(array $config, array $f): bool {
    $payload = json_encode([
        'personalizations' => [['to' => [['email' => $config['mail_to']]]]],
        'from'     => ['email' => $config['mail_from'], 'name' => 'Website contact form'],
        'reply_to' => ['email' => $f['email'], 'name' => $f['name']],
        'subject'  => '[Contact] ' . ($f['subject'] !== '' ? $f['subject'] : 'Message from ' . $f['name']),
        'content'  => [['type' => 'text/plain',
                        'value' => "From: {$f['name']} <{$f['email']}>\n\n{$f['message']}"]],
    ]);
    [$status] = post_json($config['sendgrid_url'],
        ['Authorization: Bearer ' . $config['sendgrid_key'], 'Content-Type: application/json'], $payload);
    return $status === 202;                                       // SendGrid's "accepted for delivery"
}

$f = ['name' => '', 'email' => '', 'subject' => '', 'message' => ''];
$error = ''; $sent = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    foreach ($f as $k => $_) $f[$k] = trim($_POST[$k] ?? '');
    $token = $_POST['recaptcha_token'] ?? '';

    if ($f['name'] === '' || $f['email'] === '' || $f['message'] === '') {
        $error = 'Please fill in your name, email and message.';
    } elseif (!filter_var($f['email'], FILTER_VALIDATE_EMAIL)) {
        $error = 'That email address does not look right.';
    } else {
        [$reason, $score] = check_human($config, $token);

        // 2. STORE, accepted or rejected, before anything leaves the building.
        $db->prepare('INSERT INTO contact_submissions
                (name, email, subject, message, outcome, reject_reason, recaptcha_score, ip_address)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
           ->execute([$f['name'], $f['email'], $f['subject'], $f['message'],
                      $reason ? 'rejected' : 'accepted', $reason, $score, $_SERVER['REMOTE_ADDR'] ?? null]);
        $id = $db->lastInsertId();

        if ($reason) {
            $error = 'Sorry, we could not verify that submission. Please try again, or email us directly.';
        } else {
            $emailed = send_email($config, $f);
            $db->prepare('UPDATE contact_submissions SET emailed = ? WHERE id = ?')->execute([(int)$emailed, $id]);
            $sent = true;            // stored either way, so the enquiry is safe even if the email was not
        }
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Contact</title>
  <script src="https://www.google.com/recaptcha/api.js?render=<?= e($config['recaptcha_site_key']) ?>"></script>
</head>
<body>
  <h1>Get in touch</h1>
<?php if ($sent): ?>
  <p>Thank you. Your message has arrived and I will reply soon.</p>
<?php else: ?>
  <?php if ($error): ?><p role="alert"><?= e($error) ?></p><?php endif; ?>
  <form method="post" id="contact-form">
    <p><label>Name<br><input name="name" required value="<?= e($f['name']) ?>"></label></p>
    <p><label>Email<br><input name="email" type="email" required value="<?= e($f['email']) ?>"></label></p>
    <p><label>Subject<br><input name="subject" value="<?= e($f['subject']) ?>"></label></p>
    <p><label>Message<br><textarea name="message" rows="6" required><?= e($f['message']) ?></textarea></label></p>
    <input type="hidden" name="recaptcha_token" id="recaptcha-token">
    <button type="submit">Send</button>
  </form>
  <script>
    // Ask Google for a token at the moment of sending, then submit the form with it attached.
    document.getElementById('contact-form').addEventListener('submit', function (ev) {
      ev.preventDefault();
      var form = this;
      grecaptcha.ready(function () {
        grecaptcha.execute('<?= e($config['recaptcha_site_key']) ?>', {action: 'contact'}).then(function (token) {
          document.getElementById('recaptcha-token').value = token;
          form.submit();
        });
      });
    });
  </script>
<?php endif; ?>
</body>
</html>

It does four things, in this order, and the order is the design:

  1. Validate. Are the required fields filled in, and does the email address look like one? If not, show a message and store nothing, because this is a person who made a typo, not an enquiry.
  2. Verify. check_human() asks Google about the token and returns either nothing (all clear) or a reason. Notice that every path through it ends in a decision. In particular, a missing token is a refusal, not a reason to skip the check. Robots don't load your page, so they never run Google's script and never have a token. A check that only runs when a token is present only ever checks the people.
  3. Store, whatever the answer. Accepted or rejected, the submission is written to the table, with the reason if it was refused and the score if there was one. It's stored before any email is attempted. The database is right there on your own server; the email provider is a third party across the internet who may be having a bad day.
  4. Notify, and record whether it worked. Only accepted submissions are emailed. The emailed column is then set to 1 if the provider said yes, or 0 if it didn't, so a failed email is a row you can find, not an enquiry you never hear about.

6. Try it in a browser

Visit yoursite.com/contact.php, fill it in and send it.

What you should see: "Thank you. Your message has arrived and I will reply soon." The email arrives a few seconds later. In the table there's one row:

SELECT id, outcome, reject_reason, recaptcha_score, emailed FROM contact_submissions;

id  outcome   reject_reason  recaptcha_score  emailed
 1  accepted  NULL           0.90             1

That is what a real person looks like to this form. When I sent one through this site's live form this evening, in an ordinary browser, it scored 0.90, and the notification was in my azorius.com inbox moments later. Then mark your own test so it never gets counted as a real enquiry:

UPDATE contact_submissions SET is_test = 1 WHERE id = 1;

Flag it, don't delete it. That row is your proof that the form lets real people through. You'll want that proof later, and you can't get it back once it's gone. There's more on this below.

What it's been doing on this site

This is the table behind this site's contact page, queried this evening:

SELECT outcome, reject_reason, is_test, COUNT(*) FROM contact_submissions
GROUP BY outcome, reject_reason, is_test;

outcome   reject_reason  is_test  count
accepted  NULL           0          133   <- April to August, before the gate was fixed (below)
accepted  NULL           1            4   <- my own tests, kept and labelled
rejected  low_score      0           49   <- ran Google's script, looked like a robot
rejected  no_token       0          115   <- never ran the script at all
rejected  no_token       1            2   <- me, pretending to be a robot

Everything a contact form needs to tell you is in those few lines. Since 23 August, 164 submissions have been turned away. 115 of them never loaded the page properly: they posted straight at the form, the way most spam software does. Another 49 did run Google's script and were scored as robots anyway. Every one is a row with a timestamp and its message intact, so if I ever want to know whether the threshold is too strict, I can read exactly what it refused.

And none of those 164 sent me an email.

The things that will catch you out

I made most of these on this site, so you don't have to.

1. A gate that only checks people who ask to be checked

Symptom: the form works, the emails arrive, submissions pile up, and they're nearly all spam.

Cause: my first version checked the token like this, with no else:

if (!empty($secret) && !empty($token)) {
    // verify the token, reject if the score is low
}
// ... store it and email it

If a token arrived, it was checked properly. If no token arrived, the whole check was skipped, and execution fell straight through to storing and emailing. People always have a token, because their browser runs the script. Robots never do. So the check ran on every person and no robot, from April until August. 133 submissions got in that way.

Fix: the if ($token === '') return ['no_token', null]; line at the top of check_human(). Make every path end in a decision, and treat missing proof as a no. The general version is worth carrying to any check you ever write: ask what happens when the thing being checked simply isn't there.

2. Refusals that leave no trace

Symptom: after fixing the gate, the spam stops. Or does it? A form that's now rejecting everyone, people included, produces exactly the same quiet inbox and exactly the same unchanging table.

Cause: most contact forms only store what they accept. Mine did too, for the first fortnight after the fix. In that time a visitor loaded the contact page and submitted it twenty-five seconds later, which is the right timing for a person typing a short message. They were refused. The only trace was one line in the web server's log, and it was only identifiable as a refusal because the page sent back was four kilobytes bigger than the thank-you page. Whether that was a person or a robot is now unknowable.

Fix: store the refusals too, with the reason. That's the outcome and reject_reason columns. It's the difference between knowing what your gate did and guessing.

3. Email first, store second

Symptom: your email provider has an outage, or your API key expires, and enquiries simply vanish.

Cause: sending the email before saving the enquiry, or not saving it at all.

Fix: store first, then send, then record whether the send worked. Until tonight, this site's live form did the first two and not the third: it sent the email and ignored the reply, so the database couldn't say whether any given email had actually gone. I noticed while writing this guide, and the live form now records it too. The first real test afterwards came back accepted, scored 0.90, emailed = 1. The emailed column in the code above is the fix, and on Nexus I proved it by giving the form a revoked API key. The enquiry was stored, the visitor was thanked, and the row said emailed = 0, which is a row you can find and follow up rather than an enquiry you never knew about.

4. Deleting your own test

Symptom: weeks later, you can't prove the form lets real people through.

Cause: I tested my fix in a real browser two minutes after making it. It passed with a score of 0.90. Then I deleted the row, because a test row in a production table felt untidy. Twelve days later I was staring at a nicely round number, unable to tell "the spam stopped" from "the form rejects everyone", having personally thrown away the row that answered the question.

Fix: the is_test column. Keep test rows and label them. A number with an explained exception in it is worth far more than a tidy one.

5. The wrong kind of key

Symptom: every submission while you're testing is rejected with no_score, or verify_failed.

Cause: for no_score, a v2 key or Google's always-pass test key, which returns a pass with no score. For verify_failed, a key registered for a different domain than the page you're testing on.

Fix: register a v3 (score-based) key, and add every domain you'll test from, including localhost.

The check that catches all five

The "what you should see" in step 6 shows the happy path works. These checks show the form is doing its job. I ran each of them this evening, against the copy on Nexus or the live site, and they take a few minutes in total.

1. Does it refuse a robot?

Post at the form directly, with no token, exactly as spam software does:

curl -s https://yoursite.com/contact.php \
  -d name=Robot -d email=robot@example.com -d message=Test

You want the "could not verify" message back, a new row with outcome = rejected and reject_reason = no_token, and no email. Then flag that row as a test.

2. Does it accept a person?

That's step 6: a real browser and a real submission. You want accepted, a score, and emailed = 1. Keep the row.

3. Is every refusal written down?

SELECT reject_reason, COUNT(*) FROM contact_submissions
WHERE outcome = 'rejected' AND is_test = 0 GROUP BY reject_reason;

On this site, tonight: no_token 115, low_score 49. If you ever see verifier_unreachable in quantity, your server can't reach Google. With the code above, that means people are being turned away, so treat it as urgent.

4. Did every real enquiry reach your inbox?

SELECT id, name, email, created_at FROM contact_submissions
WHERE outcome = 'accepted' AND emailed = 0;

This should be empty. Every row it returns is somebody who wrote to you and whose email never arrived. They're all safely in the table, so reply to them from there.

5. Can anyone read your secrets?

curl -s -o /dev/null -w "%{http_code}\n" https://yoursite.com/config.php
curl -s -o /dev/null -w "%{http_code}\n" https://yoursite.com/contact.php.bak

You want anything but 200. On this site tonight the first returned 404, and the backup returned 403, refused by a server rule that blocks backup-style filenames. With the config file above the web folder, the first can't be anything else.

And once a month: read the rows

Not the counts, the rows. Open the table and read what people and robots actually sent. Four months of watching a rising number, without once reading what was in it, was the real mistake behind catch number 1. The code bug itself was four words long.

What it costs

Money: nothing beyond the hosting you have. The spam check and the email provider both have free allowances that a contact form fits inside. Check them when you sign up.

Effort: about 150 lines in total, and a few DNS records. On Nexus I pushed the form down all ten paths: two typing mistakes, six kinds of refusal (one of them the verifier being unreachable), a success, and a success whose email fails. Every one did what it should. Two of my tests needed re-running because I had set them up wrong, not because the form had.

Getting an assistant to do this for you

This is a good first project to hand to an assistant, because the code is short and the shape is well known. You'll get a working form in minutes. The three things below are what make it a good one, and none of them will be there unless you ask.

Ask for the order explicitly. "Validate, verify, store whatever the outcome, then email, then record whether the email worked." An assistant left to itself will usually store only the successes, and often emails before it stores.

Ask what happens when the token is missing. This is the one question that would have saved me four months. The most natural way to write the check skips it when there's nothing to check, and the code reads perfectly well. "What does this do if a request arrives with no token at all?" costs you ten seconds.

Then make it prove both directions. Ask it to post at the form with no token and show you the rejected row, and to show you an accepted row from a real browser. A gate you've only ever seen say yes is a gate you've never seen working.

Worth it?

Yes. A contact form is the least glamorous thing in this whole series, and it's the one that faces the public.

What you get for an evening is a front door you can trust. An enquiry can't be lost to an email outage, because it was stored before the email was tried. A robot can't get in by simply not knocking. Your inbox gets the real enquiries and nothing else. And if you ever wonder whether the form is quietly turning people away, you don't have to wonder: it's a query, and the answer is a list of rows with reasons.

Mine has said no 164 times since August, and I can tell you why every one of them was refused, down to the Rolls-Royce.