Codeskill

Learn to code, step by step

Mini project: hardened public form to admin review

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.

PreviousMini project: multi-user app with roles