Tools: Ultimate Guide: A Self-Hosted PHP Contact Form Backend Without CAPTCHA

Tools: Ultimate Guide: A Self-Hosted PHP Contact Form Backend Without CAPTCHA

A Self-Hosted PHP Contact Form Backend Without CAPTCHA

The whole service

Spam defense without CAPTCHA

Why not reCAPTCHA?

Storing submissions: JSONL + flock

Validators: return null, or a reason

The webhook fan-out

Tradeoffs

Try it in 30 seconds The spam defense for a tiny contact form is older and simpler than the CAPTCHA industry wants you to believe. A hidden field, a timer, and a rate limit catch almost everything — and keep your users' lives boring. Every static site eventually needs a contact form. You get a week into a marketing site built out of Markdown and Tailwind, you ship it, and then somebody asks "wait, how do people contact us?" Your options in 2026 look something like this: I wanted the last one, but small enough to drop next to any static site and forget about. This is what I built and what I learned while building it. ðŸ“Ķ GitHub: https://github.com/sen-ltd/contact-form Four routes. That's it: PHP 8.2, Slim 4, PSR-7. No database, no Redis, no queue. Submissions go into a single JSONL file written with flock(LOCK_EX), and a webhook URL (Slack, Discord, n8n — anything that accepts JSON) is where real integrations live. The total surface is small enough that the spam defense deserves more attention than the HTTP routing, so let's start there. Automated contact-form spam is almost entirely low-effort. A bot finds a form, guesses at the fields, and POSTs whatever it thinks is plausible. It will happily fill every input it can see — including one named _honeypot that a human user never renders. It will happily POST within 50 ms of page load. And it will happily hammer the same endpoint from the same IP hundreds of times an hour. These three behaviors map to three cheap checks: None of these stop a determined human spammer. They can read the HTML, see the honeypot, wait 3 seconds, and type a message. That's fine — for a low-traffic site you will get two or three of those a week, and deleting them by hand is cheaper than inflicting reCAPTCHA on every real user. Here's the defense module. It's ~100 lines of real code with no dependencies; I'll cover the non-obvious bits below. Three things worth pointing out. The clock is injected. Every method that depends on time takes a closure, defaulting to time(). This isn't for production; it's because time-based assertions are unbearable to test otherwise. With the injected clock I can call checkTimeGate($now - 5) and deterministically assert what "5 seconds ago" means. checkHoneypot returns null on clean. This inverted shape — return null if valid, a string reason if not — lets the HTTP handler compose checks with the null coalescing operator: No try/catch ladder, no nested ifs, no bag of booleans. You get the first reason for rejection or null if everything passed. The rate limiter is a sliding window. On each hit I drop everything older than the window, count what's left, and either reject or push the new timestamp. No cron, no external store. The map is per-process so it doesn't survive a restart — that's a deliberate trade: a bot that hits you, you restart the process, and then it hits you again gives up after 5 hits anyway. If you're running behind nginx + FPM with multiple worker processes, put nginx's own rate limit in front. Because the three checks above already catch the vast majority of traffic, and adding a captcha adds: For a tiny contact form, I want zero moving parts and zero network effects. "We get a few human-written spam messages a week and delete them" is a fine failure mode. I wanted storage to meet three properties: That's a JSONL file with flock(LOCK_EX) on every append. Here's the whole write path: Why the explicit lock if I'm already opening in ab mode? POSIX guarantees that write() up to PIPE_BUF (usually 4 KiB) is atomic when a file is opened O_APPEND. That's true, but my message field allows up to 5000 characters, and UTF-8 can push that well past 4 KiB. With two concurrent POSTs of long messages on the same server, the kernel might interleave part of line A into the middle of line B. Adding an advisory lock around the write makes the guarantee independent of how big each submission is. Reads use LOCK_SH so a concurrent admin GET /submissions never sees a half-written line. It's just a shared lock during a line-by-line fgets scan, then array_reverse + slice for "newest first, limit N". There's no delete API on purpose. If the file grows too big for comfort, rotate it with cron like you would any append-only log. Every validator returns either null (valid) or a short code string. That shape composes: filter_var(FILTER_VALIDATE_EMAIL) isn't strict RFC 5322 — it rejects some technically-valid addresses and accepts a few technically-invalid ones — but it covers 100% of real email addresses you will ever see in a contact form. Don't write your own regex for this; it's the single most overdone wheel-reinvention in web programming. String lengths use mb_strlen so that "åąąį”°åĪŠéƒŽ" counts as 4 characters, not 12 bytes. A contact form where submissions silently land in a file on a server nobody checks is worse than useless. The service reads WEBHOOK_URL from the environment and, on every successful submission, POSTs the payload as JSON to that URL. Fire-and-forget, 3-second timeout, no retry: The Webhook class takes an injected transport callable so tests can assert what would have been sent without touching the network. Delivery failures are swallowed — the submission is already in the JSONL file, so at worst you miss a Slack ping and can replay from disk. This is the obvious weakness of the design: if the webhook sink is unreachable and you didn't notice, messages pile up in a file instead of in Slack. A real queue (RabbitMQ, SQS, anything with retry semantics) would be correct — but correct is heavier than the whole rest of the service put together. Trade acknowledged. The image is 52 MB on Alpine. The whole source is about 600 lines of PHP and 550 lines of PHPUnit tests (51 tests, covering validators, spam defense, JSONL append-and-list, HTTP flow, and webhook injection). Drop it next to any static site. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ final class SpamDefense { public const MIN_RENDER_SECONDS = 3; public const RATE_LIMIT_MAX = 5; public const RATE_LIMIT_WINDOW = 3600; /** @var array<string, list<int>> ip => unix timestamps */ private array $hits = []; /** @var callable(): int */ private $clock; public function __construct(?callable $clock = null) { $this->clock = $clock ?? static fn (): int => time(); } public function checkHoneypot(mixed $value): ?string { if ($value === null || $value === '') return null; return 'honeypot_filled'; } public function checkTimeGate(mixed $ts): ?string { if (!is_int($ts) && !(is_string($ts) && ctype_digit($ts))) { return 'timestamp_missing'; } $delta = ($this->clock)() - (int) $ts; if ($delta < 0) return 'timestamp_in_future'; if ($delta < self::MIN_RENDER_SECONDS) return 'submitted_too_fast'; return null; } public function checkRateLimit(string $ip): ?string { $now = ($this->clock)(); $cutoff = $now - self::RATE_LIMIT_WINDOW; $prior = array_values(array_filter( $this->hits[$ip] ?? [], static fn (int $t): bool => $t >= $cutoff )); if (count($prior) >= self::RATE_LIMIT_MAX) { $this->hits[$ip] = $prior; return 'rate_limited'; } $prior[] = $now; $this->hits[$ip] = $prior; return null; } } final class SpamDefense { public const MIN_RENDER_SECONDS = 3; public const RATE_LIMIT_MAX = 5; public const RATE_LIMIT_WINDOW = 3600; /** @var array<string, list<int>> ip => unix timestamps */ private array $hits = []; /** @var callable(): int */ private $clock; public function __construct(?callable $clock = null) { $this->clock = $clock ?? static fn (): int => time(); } public function checkHoneypot(mixed $value): ?string { if ($value === null || $value === '') return null; return 'honeypot_filled'; } public function checkTimeGate(mixed $ts): ?string { if (!is_int($ts) && !(is_string($ts) && ctype_digit($ts))) { return 'timestamp_missing'; } $delta = ($this->clock)() - (int) $ts; if ($delta < 0) return 'timestamp_in_future'; if ($delta < self::MIN_RENDER_SECONDS) return 'submitted_too_fast'; return null; } public function checkRateLimit(string $ip): ?string { $now = ($this->clock)(); $cutoff = $now - self::RATE_LIMIT_WINDOW; $prior = array_values(array_filter( $this->hits[$ip] ?? [], static fn (int $t): bool => $t >= $cutoff )); if (count($prior) >= self::RATE_LIMIT_MAX) { $this->hits[$ip] = $prior; return 'rate_limited'; } $prior[] = $now; $this->hits[$ip] = $prior; return null; } } final class SpamDefense { public const MIN_RENDER_SECONDS = 3; public const RATE_LIMIT_MAX = 5; public const RATE_LIMIT_WINDOW = 3600; /** @var array<string, list<int>> ip => unix timestamps */ private array $hits = []; /** @var callable(): int */ private $clock; public function __construct(?callable $clock = null) { $this->clock = $clock ?? static fn (): int => time(); } public function checkHoneypot(mixed $value): ?string { if ($value === null || $value === '') return null; return 'honeypot_filled'; } public function checkTimeGate(mixed $ts): ?string { if (!is_int($ts) && !(is_string($ts) && ctype_digit($ts))) { return 'timestamp_missing'; } $delta = ($this->clock)() - (int) $ts; if ($delta < 0) return 'timestamp_in_future'; if ($delta < self::MIN_RENDER_SECONDS) return 'submitted_too_fast'; return null; } public function checkRateLimit(string $ip): ?string { $now = ($this->clock)(); $cutoff = $now - self::RATE_LIMIT_WINDOW; $prior = array_values(array_filter( $this->hits[$ip] ?? [], static fn (int $t): bool => $t >= $cutoff )); if (count($prior) >= self::RATE_LIMIT_MAX) { $this->hits[$ip] = $prior; return 'rate_limited'; } $prior[] = $now; $this->hits[$ip] = $prior; return null; } } $err = $defense->checkHoneypot($body['_honeypot'] ?? null) ?? $defense->checkTimeGate($body['_ts'] ?? null) ?? Validators::all($body); if ($err !== null) { /* 422 */ } $err = $defense->checkHoneypot($body['_honeypot'] ?? null) ?? $defense->checkTimeGate($body['_ts'] ?? null) ?? Validators::all($body); if ($err !== null) { /* 422 */ } $err = $defense->checkHoneypot($body['_honeypot'] ?? null) ?? $defense->checkTimeGate($body['_ts'] ?? null) ?? Validators::all($body); if ($err !== null) { /* 422 */ } public function append(array $submission): string { $row = [ 'id' => self::generateId(), 'received_at' => gmdate('c'), 'name' => (string) ($submission['name'] ?? ''), 'email' => (string) ($submission['email'] ?? ''), 'subject' => (string) ($submission['subject'] ?? ''), 'message' => (string) ($submission['message'] ?? ''), 'client_ip' => (string) ($submission['client_ip'] ?? ''), 'user_agent' => (string) ($submission['user_agent'] ?? ''), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; $fp = fopen($this->path, 'ab'); if ($fp === false) { throw new \RuntimeException('cannot open submissions file'); } try { if (!flock($fp, LOCK_EX)) { throw new \RuntimeException('cannot lock'); } fwrite($fp, $line); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } return $row['id']; } public function append(array $submission): string { $row = [ 'id' => self::generateId(), 'received_at' => gmdate('c'), 'name' => (string) ($submission['name'] ?? ''), 'email' => (string) ($submission['email'] ?? ''), 'subject' => (string) ($submission['subject'] ?? ''), 'message' => (string) ($submission['message'] ?? ''), 'client_ip' => (string) ($submission['client_ip'] ?? ''), 'user_agent' => (string) ($submission['user_agent'] ?? ''), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; $fp = fopen($this->path, 'ab'); if ($fp === false) { throw new \RuntimeException('cannot open submissions file'); } try { if (!flock($fp, LOCK_EX)) { throw new \RuntimeException('cannot lock'); } fwrite($fp, $line); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } return $row['id']; } public function append(array $submission): string { $row = [ 'id' => self::generateId(), 'received_at' => gmdate('c'), 'name' => (string) ($submission['name'] ?? ''), 'email' => (string) ($submission['email'] ?? ''), 'subject' => (string) ($submission['subject'] ?? ''), 'message' => (string) ($submission['message'] ?? ''), 'client_ip' => (string) ($submission['client_ip'] ?? ''), 'user_agent' => (string) ($submission['user_agent'] ?? ''), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; $fp = fopen($this->path, 'ab'); if ($fp === false) { throw new \RuntimeException('cannot open submissions file'); } try { if (!flock($fp, LOCK_EX)) { throw new \RuntimeException('cannot lock'); } fwrite($fp, $line); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } return $row['id']; } public static function all(array $payload): ?string { return self::name($payload['name'] ?? null) ?? self::email($payload['email'] ?? null) ?? self::subject($payload['subject'] ?? null) ?? self::message($payload['message'] ?? null); } public static function email(mixed $v): ?string { if (!is_string($v) || trim($v) === '') return 'email_required'; if (filter_var(trim($v), FILTER_VALIDATE_EMAIL) === false) { return 'email_invalid'; } if (strlen(trim($v)) > 254) return 'email_too_long'; return null; } public static function all(array $payload): ?string { return self::name($payload['name'] ?? null) ?? self::email($payload['email'] ?? null) ?? self::subject($payload['subject'] ?? null) ?? self::message($payload['message'] ?? null); } public static function email(mixed $v): ?string { if (!is_string($v) || trim($v) === '') return 'email_required'; if (filter_var(trim($v), FILTER_VALIDATE_EMAIL) === false) { return 'email_invalid'; } if (strlen(trim($v)) > 254) return 'email_too_long'; return null; } public static function all(array $payload): ?string { return self::name($payload['name'] ?? null) ?? self::email($payload['email'] ?? null) ?? self::subject($payload['subject'] ?? null) ?? self::message($payload['message'] ?? null); } public static function email(mixed $v): ?string { if (!is_string($v) || trim($v) === '') return 'email_required'; if (filter_var(trim($v), FILTER_VALIDATE_EMAIL) === false) { return 'email_invalid'; } if (strlen(trim($v)) > 254) return 'email_too_long'; return null; } if ($webhookUrl !== '') { contact_form_webhook()->send($webhookUrl, [ 'id' => $id, 'name' => trim((string) $body['name']), 'email' => trim((string) $body['email']), 'subject' => trim((string) $body['subject']), 'message' => (string) $body['message'], 'client_ip' => $ip, 'received_at' => gmdate('c'), ], (int) (getenv('WEBHOOK_TIMEOUT') ?: 3)); } if ($webhookUrl !== '') { contact_form_webhook()->send($webhookUrl, [ 'id' => $id, 'name' => trim((string) $body['name']), 'email' => trim((string) $body['email']), 'subject' => trim((string) $body['subject']), 'message' => (string) $body['message'], 'client_ip' => $ip, 'received_at' => gmdate('c'), ], (int) (getenv('WEBHOOK_TIMEOUT') ?: 3)); } if ($webhookUrl !== '') { contact_form_webhook()->send($webhookUrl, [ 'id' => $id, 'name' => trim((string) $body['name']), 'email' => trim((string) $body['email']), 'subject' => trim((string) $body['subject']), 'message' => (string) $body['message'], 'client_ip' => $ip, 'received_at' => gmdate('c'), ], (int) (getenv('WEBHOOK_TIMEOUT') ?: 3)); } -weight: 500;">docker build -t contact-form https://github.com/sen-ltd/contact-form.-weight: 500;">git -weight: 500;">docker run --rm -p 8000:8000 -e ADMIN_TOKEN=testsecret contact-form & sleep 1 TS=$(($(date +%s) - 10)) -weight: 500;">curl -X POST http://localhost:8000/submit \ -H "Content-Type: application/json" \ -d "{\"name\":\"You\",\"email\":\"[email protected]\",\"subject\":\"Hi\",\"message\":\"A longer message here.\",\"_ts\":$TS}" # {"ok":true,"id":"s_..."} -weight: 500;">curl http://localhost:8000/submissions -H "Authorization: Bearer testsecret" -weight: 500;">docker build -t contact-form https://github.com/sen-ltd/contact-form.-weight: 500;">git -weight: 500;">docker run --rm -p 8000:8000 -e ADMIN_TOKEN=testsecret contact-form & sleep 1 TS=$(($(date +%s) - 10)) -weight: 500;">curl -X POST http://localhost:8000/submit \ -H "Content-Type: application/json" \ -d "{\"name\":\"You\",\"email\":\"[email protected]\",\"subject\":\"Hi\",\"message\":\"A longer message here.\",\"_ts\":$TS}" # {"ok":true,"id":"s_..."} -weight: 500;">curl http://localhost:8000/submissions -H "Authorization: Bearer testsecret" -weight: 500;">docker build -t contact-form https://github.com/sen-ltd/contact-form.-weight: 500;">git -weight: 500;">docker run --rm -p 8000:8000 -e ADMIN_TOKEN=testsecret contact-form & sleep 1 TS=$(($(date +%s) - 10)) -weight: 500;">curl -X POST http://localhost:8000/submit \ -H "Content-Type: application/json" \ -d "{\"name\":\"You\",\"email\":\"[email protected]\",\"subject\":\"Hi\",\"message\":\"A longer message here.\",\"_ts\":$TS}" # {"ok":true,"id":"s_..."} -weight: 500;">curl http://localhost:8000/submissions -H "Authorization: Bearer testsecret" - Formspree, Netlify Forms, Basin, Getform — easy, free-tier capped, every submission leaks through a third party. - reCAPTCHA / hCaptcha / Turnstile — free, but punishes every user who blocks trackers. Also ties you to Google / Intuition / Cloudflare. - SMTP — "I'll just send myself an email." Brittle, slow, and six months later nobody's reading that inbox. - Your own tiny HTTP -weight: 500;">service — three files of PHP, zero third parties. - Honeypot field — a hidden _honeypot input the user never sees. If it comes back populated, the sender is a bot. - Time gate — the browser writes _ts = Date.now()/1000 at form-render time. A real user takes at least a few seconds to type a message; a submission less than 3 seconds after render is almost certainly scripted. - Rate limit — no matter how clever the sender, you cap each IP at 5 submissions per hour. That's far above any sincere contact rate and well below any spam run. - A third-party script dependency (tracking, privacy, load time). - A user-experience hit for real visitors, especially on mobile and especially for anyone using a privacy-focused browser. - An accessibility regression — image captchas are still horrible for screen reader users. - A fallback path I still have to maintain when Google changes the API. - Zero external dependencies (no MySQL, no Postgres, no SQLite libraries pulled in from Composer). - Append-only audit log — never rewrite, never delete, never "hmm which row did the schema change on?" - Crash-safe concurrent writes: if two people submit at the same time, no line may be torn in half. - No CAPTCHA, deliberately. Determined humans get through. Accepted cost. - JSONL is append-only. You can't "delete" a submission through the API. Edit the file by hand. - Webhook isn't queued. Delivery failures drop on the floor. JSONL is the source of truth. - Rate limiter is in-memory, per-process. Multi-worker setups need upstream rate limiting. - No SMTP. Email delivery is somebody else's problem — point the webhook at an email relay if you need one.