THU.JUN.18
2026
23:35:10
← back to modules MODULE · 02 · PHP PART 2
0 / 10 chapters complete · 0%

Forms — Handling GET and POST Properly

The whole point of a web server is receiving input from users and responding to it. Forms are the standard way users send input. This chapter is the complete, defensive playbook for handling forms safely — validation, CSRF protection, file uploads, the POST-Redirect-GET pattern. Real production patterns that you'll use forever.
Default → cast → validate → use. GET for navigation, POST for actions. Always escape on output. Always CSRF-protect POSTs. POST-Redirect-GET after every successful save.

The superglobals you'll use

"Superglobal" means: they're automatically available in every scope, no imports or declarations needed. PHP just puts them there for you when a request comes in.

  • $_GET — URL query parameters (?id=42&sort=desc)
  • $_POST — form body data, for forms with method="post"
  • $_REQUEST — both GET and POST merged. Don't use this — be explicit.
  • $_FILES — uploaded files
  • $_COOKIE — cookies the browser sent
  • $_SESSION — server-side session storage (next chapter)
  • $_SERVER — request headers, server info, environment variables

All of these are arrays. All are populated by PHP before your code starts running. And critically: all contain untrusted input from the outside world. Treat the values as hostile until you've validated them.

Mental model: incoming user data is like packages arriving at a warehouse. You don't just throw them on shelves — you inspect first. Is this what we expected? Is anything suspicious? Then sort and store. The "inspect" step is validation. The cost of forgetting this step is bugs at best, security holes at worst.

🐍 Python (Flask): If you've used Flask, $_GETrequest.args, $_POSTrequest.form, $_FILESrequest.files, $_SESSIONsession, $_COOKIErequest.cookies, $_SERVERrequest.headers + request.environ. PHP exposes them as globals; Flask gives you a request object.

A simple HTML form

<form method="post" action="/submit.php">
  <label>Name: <input name="name" type="text"></label>
  <label>Age:  <input name="age"  type="number"></label>
  <button type="submit">Send</button>
</form>

Key things to notice. method="post" submits using a POST request — body data, not URL parameters. action="/submit.php" says where the form posts to. The name="..." attributes on inputs are what become keys in $_POST on the server. No name → no value submitted. Tiny detail, big effect.

The four-step pattern — drill this into muscle memory

Every form handler follows the same four-step structure. Drill this:

<?php
declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Method not allowed');
}

// 1. Default — never assume the key exists
$name = trim($_POST['name'] ?? '');
$age  = (int) ($_POST['age']  ?? 0);

// 2. Cast — already done above with (int)

// 3. Validate
$errors = [];
if ($name === '')           $errors[] = 'Name is required.';
if (mb_strlen($name) > 100)  $errors[] = 'Name too long.';
if ($age < 0 || $age > 150)  $errors[] = 'Age looks wrong.';

if ($errors) {
    http_response_code(400);
    foreach ($errors as $e) echo "<p>" . htmlspecialchars($e) . "</p>";
    exit;
}

// 4. Use — only after validation passes, use the data
echo "Hello, " . htmlspecialchars($name) . " (age $age).";

The pattern: default → cast → validate → use. Default protects against missing keys. Cast turns strings into the type you expect. Validate checks the data is reasonable. Use happens only after validation succeeds. Skip a step at your peril. Every form handler in every PHP app you ever write follows this shape.

GET for navigation, POST for actions

Foundational web rule that's worth understanding deeply: GET requests must be safe and idempotent.

  • "Safe" means GET shouldn't change anything on the server.
  • "Idempotent" means hitting the URL twice does the same thing as hitting it once.

Why does this matter? Because browsers, search engines, link-checkers, and previewers all freely "follow" GET URLs to see what's there. If you put "delete this post" behind a GET URL, you'll wake up one day to a wiped database — Google's crawler will have helpfully clicked every delete link as it indexed your site.

  • GET: reading a page, filtering a list, navigating tabs, search queries. Bookmark-friendly.
  • POST: create, update, delete. Anything that mutates state.

This isn't just convention. It's how the entire web works at a deeper level. Follow it.

Repopulate forms on validation failure

Users absolutely hate retyping. If validation fails, render the form again with the values they submitted, so they only have to fix the broken bits:

<input name="name" value="<?= e($_POST['name'] ?? '') ?>">

CRITICAL detail: escape the value with htmlspecialchars (the e() helper). The user might have typed "><script>...</script> as their name (yes, people really test this — sometimes for fun, sometimes for malicious reasons). Without escaping, you've just opened an XSS hole.

CSRF — the attack you don't see coming

Settle in for a quick security story, because this one matters.

Picture this attack scenario: an attacker sends you a link to cute-cats.html. You click it because, cats. The page looks innocent, but in the background it has a hidden form that auto-submits to your-bank.com/transfer?to=attacker&amount=9999. Since you're already logged into your bank, your browser includes your login cookies with the request. Your bank sees a valid authenticated request and processes it. You just lost $9999 because you wanted to look at cats.

That's CSRF (Cross-Site Request Forgery). The attacker doesn't need your password — they just need to trick your browser into making a request while you're authenticated somewhere.

The fix: include a secret token in every form that an attacker on another site can't possibly know. Then verify the token on submission.

// On the form-render side
session_start();
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<form method="post">
  <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
  <!-- ... -->
</form>
<?php

// On the form-handler side
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
    http_response_code(403);
    exit('CSRF token mismatch');
}

How it works: on the form page, generate a random token and store it in the user's session. Include it in the form as a hidden field. On submission, check the token in the form matches the session. An attacker on a different site can't read your session data, so they can't include the right token. Even if they trick your browser into making the request, it fails the token check.

Use hash_equals for the comparison, not ===. hash_equals does constant-time comparison, which prevents "timing attacks" where an attacker measures how long the comparison took to gradually guess the token character by character. Tiny cost, real protection. Yes, even on tiny personal apps. The habit is cheap; not doing it is dangerous.

File uploads

Forms can also upload files. Two things change from a regular form:

<form method="post" enctype="multipart/form-data">
  <input type="file" name="avatar">
  <button type="submit">Upload</button>
</form>

The enctype="multipart/form-data" is critical — without it, browsers only send the filename, not the actual file bytes. Easy thing to forget; impossible to debug if you don't know about it.

On the server, uploads land in $_FILES:

<?php
if ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
    exit('Upload failed: ' . $_FILES['avatar']['error']);
}

$tmp  = $_FILES['avatar']['tmp_name'];     // temp path PHP saved it to
$name = $_FILES['avatar']['name'];          // user-provided filename — DO NOT trust
$size = $_FILES['avatar']['size'];          // bytes
$type = mime_content_type($tmp);            // detect actual mime from content

if ($size > 5_000_000) exit('Too big');
if (!in_array($type, ['image/jpeg','image/png','image/webp'], true)) {
    exit('Not an image');
}

// Generate a safe destination name — never use the original filename
$ext  = match($type) {
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
};
$dest = __DIR__ . '/uploads/' . bin2hex(random_bytes(8)) . ".$ext";

if (!move_uploaded_file($tmp, $dest)) {
    exit('Save failed');
}

Security notes worth absorbing:

  • Don't trust the user's filename. Attackers love naming files "image.png.php" — Apache might serve it as PHP code, instantly compromising your server. Always rename uploaded files to a name YOU generate.
  • Use mime_content_type to detect the real type from file contents, not the user's claim. Extensions and MIME headers are easy to fake.
  • Validate size BEFORE doing anything else. Otherwise an attacker can upload 10 GB files and DoS your disk.
  • Store uploads OUTSIDE the web root when possible, and serve them through a controller. Nothing the user uploads can ever be executed as PHP that way.

POST-Redirect-GET — the pattern

After a successful POST, redirect to a GET page. Don't render content directly from the POST handler.

// after successful save
header('Location: /thanks.php');
exit;

Why? Because if the user hits "back" or "refresh" after a POST, the browser asks "want to resubmit the form?" Most users will click yes without reading. That means double-submits — duplicate records, duplicate emails, duplicate payments.

By redirecting to a GET page after the POST, the user's browser history shows the GET URL, not the POST. Hitting back/refresh just reloads the result page, doesn't resubmit. Smooth UX, no duplicate writes.

The exit; after header() is mandatory — without it, PHP continues executing the rest of the page, which can produce output before the redirect actually takes effect (depending on output buffering). Always pair them.

Build: Add Med Form (in-memory)

Real form handling time. We'll build a page that lets you add a new med to a list, with proper validation, CSRF protection, and the POST-Redirect-GET pattern. The data is session-backed for now — we'll wire it to a real database in chapter 8.

  1. Create /home/erictey/server/add-med.php.
  2. Paste this in:
    <?php
    declare(strict_types=1);
    session_start();
    require __DIR__ . '/lib/meds-helpers.php';
    
    $_SESSION['meds'] ??= [];
    $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
    
    $errors = [];
    $old = ['name' => '', 'dose' => '', 'status' => 'due'];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
            http_response_code(403);
            exit('CSRF token mismatch');
        }
    
        $name   = trim((string)($_POST['name'] ?? ''));
        $dose   = (int)($_POST['dose'] ?? 0);
        $status = (string)($_POST['status'] ?? 'due');
    
        $old = compact('name', 'dose', 'status');
    
        if ($name === '')               $errors[] = 'Name is required.';
        if (mb_strlen($name) > 100)      $errors[] = 'Name too long.';
        if ($dose <= 0 || $dose > 10000) $errors[] = 'Dose must be 1-10000 mg.';
        if (!in_array($status, ['due','taken','overdue','skipped'], true)) {
            $errors[] = 'Invalid status.';
        }
    
        if (!$errors) {
            $_SESSION['meds'][] = compact('name', 'dose', 'status');
            $_SESSION['flash'] = "Added: $name";
            header('Location: /add-med.php');
            exit;
        }
    }
    
    $flash = $_SESSION['flash'] ?? null;
    unset($_SESSION['flash']);
    ?>
    <!DOCTYPE html>
    <html>
    <head>
      <style>
        body { background:#07050d; color:#f0e9ff; font-family:monospace; padding:30px; max-width:600px; }
        label { display:block; margin-top:12px; color:#5bf0ff; }
        input, select { display:block; width:100%; background:#110a1c; border:1px solid #ff2e88; color:#f0e9ff; padding:6px 10px; font-family:monospace; }
        button { margin-top:16px; background:#ff2e88; border:none; color:#07050d; padding:8px 20px; font-family:monospace; cursor:pointer; }
        .errors { color:#ff4e6a; border-left:3px solid #ff4e6a; padding:8px 12px; background:rgba(255,78,106,0.1); }
        .flash { color:#4eff7a; border-left:3px solid #4eff7a; padding:8px 12px; background:rgba(78,255,122,0.1); }
        .med { padding:8px; border-left:3px solid #ff2e88; margin:4px 0; background:rgba(255,255,255,0.03); }
      </style>
    </head>
    <body>
      <h1 style="color:#ff2e88">Add Med</h1>
    
      <?php if ($flash): ?>
        <div class="flash">✓ <?= e($flash) ?></div>
      <?php endif; ?>
    
      <?php if ($errors): ?>
        <div class="errors">
          <?php foreach ($errors as $err): ?>
            <div>✗ <?= e($err) ?></div>
          <?php endforeach; ?>
        </div>
      <?php endif; ?>
    
      <form method="post">
        <input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
        <label>Name</label>
        <input name="name" value="<?= e((string)$old['name']) ?>" autofocus>
        <label>Dose (mg)</label>
        <input name="dose" type="number" value="<?= e((string)$old['dose']) ?>">
        <label>Status</label>
        <select name="status">
          <?php foreach (['due','taken','overdue','skipped'] as $s): ?>
            <option value="<?= $s ?>" <?= $old['status']===$s?'selected':'' ?>><?= $s ?></option>
          <?php endforeach; ?>
        </select>
        <button>Add Med</button>
      </form>
    
      <h2 style="color:#5bf0ff;margin-top:30px">Your meds (<?= count($_SESSION['meds']) ?>)</h2>
      <?php foreach ($_SESSION['meds'] as $med): ?>
        <div class="med">
          <strong><?= e($med['name']) ?></strong>
          <?= format_dose((int)$med['dose']) ?> — <?= e($med['status']) ?>
        </div>
      <?php endforeach; ?>
    </body>
    </html>
  3. Visit http://192.168.0.19/add-med.php.
  4. Try submitting empty — should see validation errors.
  5. Try a real submission — should redirect (POST-Redirect-GET), show success flash, and new med appears.
  6. Refresh after submitting — the form does NOT resubmit. POST-Redirect-GET is working.

Stretch goals:

  • Add a "Delete" button next to each med — sends a POST with the med index, removes from session array.
  • Test CSRF protection: manually edit the hidden csrf field in DevTools and submit. Should get a 403.
  • Add a "next dose at" datetime field for each med.

What you flexed: session storage with ??= for init, CSRF tokens with hash_equals, the four-step default→cast→validate→use pipeline, form repopulation with escaped values, flash messages, POST-Redirect-GET. This is genuinely production-shaped form handling.