This mini project is a public contact-style form with proper hardening – rate limits, CSRF, validation, honeypot – and an admin queue to review submissions. No login required to submit; admins work behind auth. Good practice for any site that collects messages from strangers.
What you are building
- Public GET/POST form (name, email, message)
- Submissions stored as JSON files or rows – status:
pending,approved,spam - Rate limit by IP (middleware from the security lesson)
- Honeypot field hidden from real users; bots fill it
- Admin list with mark-as-spam and approve actions
- Optional: queue email notification to admin on new submission
Public form template
<form method="post" action="/contact">
<input type="hidden" name="_token" value="<?= esc($csrf) ?>">
<!-- honeypot: leave empty -->
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label>Company<input type="text" name="company" tabindex="-1" autocomplete="off"></label>
</div>
<label>Name <input name="name" required maxlength="100"></label>
<label>Email <input type="email" name="email" required maxlength="190"></label>
<label>Message <textarea name="message" required maxlength="2000"></textarea></label>
<button type="submit">Send</button>
</form>
Submission handler
final class AcceptSubmission
{
public function __construct(
private SubmissionRepository $repo,
private RateLimiter $limiter,
) {}
public function handle(array $input, string $ip): int
{
if ($this->limiter->tooManyAttempts('contact:' . $ip, 5, 3600)) {
throw new TooManyRequestsException();
}
if (($input['company'] ?? '') !== '') {
throw new SpamDetectedException();
}
$name = trim($input['name'] ?? '');
$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
$message = trim($input['message'] ?? '');
if ($name === '' || !$email || $message === '') {
throw new ValidationException('Please fill in all fields correctly.');
}
return $this->repo->store([
'name' => $name,
'email' => $email,
'message' => $message,
'ip' => $ip,
'status' => 'pending',
'created_at' => date('c'),
]);
}
}
File storage (simple option)
final class FileSubmissionRepository
{
public function __construct(private string $dir) {}
public function store(array $data): int
{
$id = (int) (microtime(true) * 1000);
$path = $this->dir . '/' . $id . '.json';
file_put_contents($path, json_encode(['id' => $id] + $data, JSON_PRETTY_PRINT), LOCK_EX);
return $id;
}
/** @return list<array> */
public function pending(): array
{
$out = [];
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
$row = json_decode(file_get_contents($file), true);
if (($row['status'] ?? '') === 'pending') {
$out[] = $row;
}
}
usort($out, fn ($a, $b) => strcmp($b['created_at'], $a['created_at']));
return $out;
}
}
Admin review
Protected routes list pending items. Approve marks status and optionally sends a reply template. Spam moves the file to a spam/ folder or updates status – do not delete immediately if you want to study patterns.
public function markSpam(int $id): void
{
requireAuth();
requireRole('admin');
verifyCsrf();
$this->submissions->updateStatus($id, 'spam');
$this->audit->record(currentUserId(), 'submission.spam', ['id' => $id]);
}
Build order
- 1. Routes: public contact, admin inbox, POST actions
- 2. Rate limit + CSRF middleware on POST
- 3. AcceptSubmission with validation and honeypot
- 4. File or DB repository
- 5. Admin templates – escape all output
- 6. Optional job: email admin on new pending submission
- 7. Test: honeypot filled returns silently or 422; sixth submit in an hour gets 429
Done when a bot-filled honeypot never reaches the inbox, real users get a thank-you page, and admins can clear the queue without touching raw files on the server.

