Forms & User Input

Reviewed & published by Brayan K

By the end of this lesson you'll be able to read form data from $_GET and $_POST, sanitize and validate it, re-display it safely, handle file uploads, and protect your forms with CSRF tokens — the security skills every PHP app depends on.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Where Form Data Arrives: $_GET, $_POST, $_REQUEST

When a visitor submits a form, PHP hands you the data in a superglobal — a built-in array you can read from anywhere. A form with method="get" puts its data in the URL and you read it from $_GET; a form with method="post" sends it in the hidden request body and you read it from $_POST. Use GET for harmless reads (a search, a page number) where a shareable URL helps, and POST for anything sensitive or state-changing (logins, sign-ups, comments). There's also $_REQUEST, which merges both — convenient, but it hides where the data came from, so prefer the specific one.

<?php
// When a browser submits a form, PHP fills one of these "superglobals"
// (built-in arrays you can read anywhere). In a real app the browser sets
// them; here we assign them directly so the example runs anywhere.

// A form with method="get" puts data in the URL  ?q=php&page=2
$_GET = ["q" => "php", "page" => "2"];

// A form with method="post" sends data in the request body (not the URL)
$_POST = ["username" => "ada", "password" => "secret"];

echo "From the URL (GET):\n";
echo "  search term: " . $_GET["q"] . "\n";     // php
echo "  page number: " . $_GET["page"] . "\n";  // 2

echo "\nFrom the form body (POST):\n";
echo "  username: " . $_POST["username"] . "\n"; // ada

// $_REQUEST merges GET + POST (+ cookies). Convenient, but you lose track of
// WHERE data came from — prefer the specific $_GET or $_POST in real code.
// PHP builds it once, as the request arrives, so assigning $_GET and $_POST
// above did not refill it — do that here too.
$_REQUEST = $_GET + $_POST;
echo "\n" . '$_REQUEST also sees it: ' . $_REQUEST["username"] . "\n";
?>

2️⃣ Sanitize: Make Input Safe with htmlspecialchars

Never trust user input. If you echo what someone typed straight back onto the page and they typed <script>, that script runs in your other visitors' browsers — that's an XSS (cross-site scripting) attack. The fix is to escape the output: htmlspecialchars() converts the dangerous characters < > " ' & into harmless HTML entities, so the browser shows the tag as text instead of running it. Pair it with trim() to drop stray whitespace, and the ?? operator to supply a fallback when a field is missing.

<?php
// A user typed a comment containing a <script> tag — a classic XSS attempt.
// "XSS" (cross-site scripting) is when attacker HTML runs in another user's
// browser. Escaping the output is what stops it.
$_POST = ["comment" => "Nice post! <script>steal(cookies)</script>"];

// 1) Read safely. The ?? operator gives a fallback if the key is missing,
//    so you never hit an "Undefined array key" warning.
$raw = $_POST["comment"] ?? "";

// 2) trim() removes leading/trailing spaces a user often leaves behind.
$raw = trim($raw);

// 3) htmlspecialchars() turns < > " ' & into harmless entities so the
//    browser DISPLAYS the tag as text instead of RUNNING it.
$safe = htmlspecialchars($raw, ENT_QUOTES, "UTF-8");

echo "Raw (dangerous):  $raw\n";
echo "Safe (escaped):   $safe\n";
// Notice <script> became &lt;script&gt; — it can no longer execute.
?>

See how <script> became &lt;script&gt; in the safe version? Those entities display as literal angle brackets — the browser will never execute them. Pass ENT_QUOTES so both single and double quotes are escaped too, which matters the moment you put a value inside an HTML attribute.

3️⃣ Validate: Check Input Is Correct with filter_var

Sanitizing makes input safe to display; validating checks it's actually correct — that an email is a real email and an age is a number in range. PHP's filter_var() does this for common types without you writing any regex: FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_INT (with optional min_range/max_range), FILTER_VALIDATE_URL and more. It returns the cleaned value on success or false on failure. For required fields, the simplest rule is "must not be empty after trim()". Collect every problem into an $errors array so you can report them all at once.

<?php
// Sanitizing makes input SAFE to display. Validating checks it's CORRECT.
// You almost always do both: sanitize, then validate.
$_POST = [
    "name"  => "  Alice  ",
    "email" => "[email protected]",
    "age"   => "28",
];

$errors = [];                                   // collect problems as we go

// Required field: must not be empty after trimming.
$name = trim($_POST["name"] ?? "");
if ($name === "") {
    $errors[] = "Name is required";
}

// filter_var validates common types WITHOUT writing your own regex.
$email = filter_var($_POST["email"] ?? "", FILTER_VALIDATE_EMAIL);
if ($email === false) {                         // returns false when invalid
    $errors[] = "A valid email is required";
}

// Validate an integer AND a range in one call.
$age = filter_var($_POST["age"] ?? "", FILTER_VALIDATE_INT, [
    "options" => ["min_range" => 13, "max_range" => 120],
]);
if ($age === false) {
    $errors[] = "Age must be a whole number from 13 to 120";
}

if (empty($errors)) {                           // empty() is true for []
    echo "All good! Welcome, $name ($age).\n";
} else {
    echo "Please fix:\n";
    foreach ($errors as $e) {
        echo "  - $e\n";
    }
}
?>

4️⃣ Re-Display Values Safely (Sticky Forms)

When validation fails you should send the form back with the user's answers still in the boxes — a sticky form — so they don't retype everything. The trap: you're putting user data back into HTML, so the same XSS risk returns. The rule is absolute — escape every value before it re-enters the page, including values you place inside value="..." attributes. A one-line safe() helper that wraps htmlspecialchars() means you can never forget.

<?php
// "Sticky" forms keep what the user typed when validation fails, so they
// don't have to retype everything. The golden rule: ALWAYS escape a value
// before putting it back into HTML, or you reopen the XSS hole.
$_POST = ["name" => 'Bob "the builder"', "email" => "not-an-email"];

$name  = trim($_POST["name"] ?? "");
$email = trim($_POST["email"] ?? "");
$valid = filter_var($email, FILTER_VALIDATE_EMAIL) !== false;

// A tiny helper so we never forget to escape when re-displaying.
function safe(string $v): string {
    return htmlspecialchars($v, ENT_QUOTES, "UTF-8");
}

// This is the HTML PHP would send back to the browser on a failed submit:
echo '<input name="name"  value="'  . safe($name)  . '">' . "\n";
echo '<input name="email" value="' . safe($email) . '">' . "\n";
if (!$valid) {
    echo '<p class="error">Please enter a valid email.</p>' . "\n";
}
// The quote in 'Bob "the builder"' became &quot; so it can't break out of
// the value="..." attribute. That's exactly why you escape.
?>

The double quote in Bob "the builder" became &quot;, so it can't close the value="..." attribute early and inject new HTML. That single substitution is the difference between a working form and a hijacked one.

5️⃣ File Uploads with $_FILES

Uploaded files don't appear in $_POST — they arrive in $_FILES, and only if the form uses method="post" with enctype="multipart/form-data". PHP saves the upload to a temporary path and gives you its name, type, tmp_name, error and size. Validate all of it: check error === UPLOAD_ERR_OK, cap the size, and whitelist the type. Then move_uploaded_file() moves it out of the temp folder. Crucially, generate your own filename — never reuse the user's, which could be ../../evil.php.

<?php
// File uploads arrive in $_FILES, not $_POST. The form must use
// enctype="multipart/form-data" and method="post" for this to fill.
// Here we fake one entry so the validation logic is runnable.
$_FILES = [
    "avatar" => [
        "name"     => "me.png",        // original filename (DON'T trust it)
        "type"     => "image/png",     // browser-claimed type (DON'T trust it)
        "tmp_name" => "/tmp/php3F2a",  // where PHP stored the upload
        "error"    => 0,               // 0 (UPLOAD_ERR_OK) means success
        "size"     => 48213,           // bytes
    ],
];

$file   = $_FILES["avatar"];
$errors = [];

// 1) Check the upload itself succeeded.
if ($file["error"] !== UPLOAD_ERR_OK) {
    $errors[] = "Upload failed (error code " . $file["error"] . ")";
}

// 2) Enforce a size limit (here 2 MB) — never accept unbounded files.
if ($file["size"] > 2 * 1024 * 1024) {
    $errors[] = "File too large (max 2 MB)";
}

// 3) Whitelist the REAL type. In real code you'd use finfo on tmp_name
//    instead of trusting $file["type"], which the browser can fake.
$allowed = ["image/png", "image/jpeg", "image/webp"];
if (!in_array($file["type"], $allowed, true)) {
    $errors[] = "Only PNG, JPEG or WebP images are allowed";
}

if (empty($errors)) {
    // 4) move_uploaded_file() safely moves it out of the temp folder.
    //    Generate your OWN name — never reuse the user's filename.
    $newName = "uploads/" . bin2hex(random_bytes(8)) . ".png";
    // The name is random on purpose — two people uploading me.png must not
    // collide — so print its shape rather than one run's answer.
    echo "Valid upload — would save to: uploads/<16 hex chars>.png\n";
    // move_uploaded_file($file["tmp_name"], $newName);  // (real code)
} else {
    foreach ($errors as $e) {
        echo "  - $e\n";
    }
}
?>

Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.

<?php
// 🎯 YOUR TURN — sanitize and validate a sign-up field.
// Fill in each blank marked ___ using the 👉 hint, then run it.
$_POST = ["email" => "  [email protected]  "];

// 1) Read the email safely with a fallback, then trim and lowercase it.
$email = strtolower(trim($_POST["email"] ___ ""));   // 👉 the "fallback" operator is  ??

// 2) Validate it. filter_var returns false if it's not a real email.
$valid = filter_var($email, ___) !== false;          // 👉 the email filter is  FILTER_VALIDATE_EMAIL

if ($valid) {
    echo "OK: $email\n";
} else {
    echo "Bad email\n";
}

// ✅ Expected output:
//    OK: [email protected]
?>

One more, this time about safe output. A user submitted HTML in a comment — escape it so it can't run.

<?php
// 🎯 YOUR TURN — re-display a comment SAFELY (block the XSS).
// A user submitted HTML. Escape it before echoing it back into the page.
$_POST = ["comment" => "Cool <b>tip</b> <script>alert(1)</script>"];

$comment = trim($_POST["comment"] ?? "");

// 👉 Wrap $comment so < > " ' & become harmless entities.
$safe = ___($comment, ENT_QUOTES, "UTF-8");   // 👉 the function is  htmlspecialchars

echo "<p>$safe</p>\n";

// ✅ Expected output:
//    <p>Cool &lt;b&gt;tip&lt;/b&gt; &lt;script&gt;alert(1)&lt;/script&gt;</p>
?>

6️⃣ Protect Forms with a CSRF Token

CSRF (cross-site request forgery) is a sneaky attack: a malicious page makes a logged-in user's browser submit your form without them realising — and because the browser automatically attaches their session cookie, your server thinks it's a genuine request. The defence is a CSRF token: a long random secret you store in the user's session and embed as a hidden field in every state-changing form. When the form comes back, you compare the submitted token to the stored one with hash_equals() (a timing-safe comparison — never use == for secrets). An attacker can't read or guess the token, so forged submissions are rejected.

<?php
// CSRF (cross-site request forgery) tricks a logged-in user's browser into
// submitting YOUR form from an attacker's page. The fix: put a secret,
// per-session token in the form and check it on submit. An attacker can't
// guess the token, so forged submissions are rejected.

// --- When you SHOW the form ---
// session_start();  // (real code) loads/creates the user's session
$_SESSION = [];                                       // pretend session store
$_SESSION["csrf"] = bin2hex(random_bytes(32));        // 64-char random secret

$token = $_SESSION["csrf"];
echo "Form would include this hidden field:\n";
// $token is 64 random hex characters, different every time, so show its
// shape here instead of one value you could never reproduce.
echo '  <input type="hidden" name="csrf" value="<' . strlen($token) . ' random hex chars>">' . "\n\n";

// --- When the form is SUBMITTED back ---
$_POST = ["csrf" => $token];                          // the form sent it back

// hash_equals() compares safely (constant time, no timing leaks).
// NEVER use == here.
if (hash_equals($_SESSION["csrf"], $_POST["csrf"] ?? "")) {
    echo "Token matches — request is genuine, process it.\n";
} else {
    echo "Token missing/wrong — reject the request (403).\n";
}
?>

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Forms & Input

ToolExampleWhat It Does
$_GET / $_POST$_POST["name"]Read submitted form data
??$_POST["x"] ?? ""Fallback if the key is missing
trim()trim(" hi ")Strip surrounding whitespace
htmlspecialchars()htmlspecialchars($s, ENT_QUOTES)Escape HTML to stop XSS
filter_var()filter_var($e, FILTER_VALIDATE_EMAIL)Validate email/int/URL/IP
$_FILES$_FILES["avatar"]["tmp_name"]Access an uploaded file
move_uploaded_file()move_uploaded_file($tmp, $dest)Save an upload safely
hash_equals()hash_equals($a, $b)Timing-safe token compare (CSRF)

Mini-Challenge: A Contact-Form Handler

No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the read-the-input, validate, escape, respond loop behind every real form.

<?php
// 🎯 MINI-CHALLENGE: a contact-form handler.
// No code is filled in — work from the steps, then run it.
//
// Given this input:
$_POST = ["name" => "  ", "email" => "[email protected]", "message" => "Hi <there>"];
//
// 1. Read name, email, message safely with  ?? ""  and  trim().
// 2. Build an $errors array:
//      - add "Name is required"  if the trimmed name is ""
//      - add "Invalid email"     if filter_var(...FILTER_VALIDATE_EMAIL) is false
// 3. Escape the message with htmlspecialchars(..., ENT_QUOTES, "UTF-8").
// 4. If $errors is empty, echo "Sent: <escaped message>".
//    Otherwise echo each error on its own line, prefixed with "  - ".
//
// ✅ Expected output (name is blank, so one error):
//      - Name is required

// your code here
?>

Lesson Complete!

Practice quiz

Which superglobal holds data from a form submitted with method="post"?

  • $_GET
  • $_SERVER
  • $_POST
  • $_COOKIE

Answer: $_POST. A method="post" form sends data in the request body, which PHP exposes in $_POST. method="get" data lands in $_GET.

What does the ?? operator do when reading $_POST["name"] ?? ""?

  • Supplies a fallback if the key is missing, avoiding a warning
  • Validates the value
  • Escapes the value
  • Forces it to a string type

Answer: Supplies a fallback if the key is missing, avoiding a warning. The null coalescing operator ?? gives a fallback when the key is absent, so you never hit an 'Undefined array key' warning.

What is the difference between sanitizing and validating input?

  • They are the same thing
  • Validating escapes HTML; sanitizing checks ranges
  • Both only run on the client
  • Sanitizing makes input safe to use/display; validating checks it is correct

Answer: Sanitizing makes input safe to use/display; validating checks it is correct. Sanitizing (e.g. htmlspecialchars) makes input safe; validating (e.g. filter_var) confirms it is correct. You usually do both.

Which function escapes < > " ' & so user input can't run as HTML (stopping XSS)?

  • strip_tags()
  • htmlspecialchars()
  • trim()
  • filter_var()

Answer: htmlspecialchars(). htmlspecialchars() turns those characters into harmless entities so the browser displays the tag as text instead of running it.

Why pass ENT_QUOTES to htmlspecialchars when putting a value inside value="..."?

  • It escapes both single and double quotes so the value can't break out of the attribute
  • It speeds up escaping
  • It validates the email
  • It removes whitespace

Answer: It escapes both single and double quotes so the value can't break out of the attribute. ENT_QUOTES escapes both quote types, so a double quote becomes &quot; and can't close the value="..." attribute early.

What does filter_var($email, FILTER_VALIDATE_EMAIL) return for an invalid email?

  • An empty string
  • null
  • false
  • The original string

Answer: false. filter_var returns the cleaned value on success or false on failure, so === false signals an invalid email.

Why isn't client-side (JavaScript) validation enough on its own?

  • It is too slow
  • Users can disable JS or post directly with curl, bypassing it — the server must re-check
  • It can't validate emails
  • It only works in Chrome

Answer: Users can disable JS or post directly with curl, bypassing it — the server must re-check. The user controls the client. JS validation is just UX; every rule that matters must be enforced again on the server.

Where do uploaded files arrive in PHP?

  • $_POST
  • $_GET
  • $_SESSION
  • $_FILES

Answer: $_FILES. Uploads arrive in $_FILES (with form enctype="multipart/form-data"), giving name, type, tmp_name, error and size.

Which function should compare a submitted CSRF token to the stored one?

  • ==
  • hash_equals()
  • ===
  • strcmp()

Answer: hash_equals(). hash_equals() is a timing-safe comparison for secrets. Never use == for token comparison.

For a stored file upload, what name should you save it under?

  • The user's original filename
  • The browser-supplied type
  • A name you generate yourself (e.g. random bytes)
  • Always 'upload.tmp'

Answer: A name you generate yourself (e.g. random bytes). Never reuse the user's filename (it could be ../../evil.php). Generate your own name and move it with move_uploaded_file().

Continue this course

Frequently asked questions

Should I use $_GET or $_POST for a form?

Use $_POST for anything that changes data or is sensitive — logins, sign-ups, payments, posting a comment. POST data travels in the request body, not the URL, so it isn't bookmarked, logged in the address bar, or shown in browser history. Use $_GET for things that only read or filter, like a search box or pagination, where having the values in a shareable URL is actually useful. Avoid $_REQUEST in new code: it merges GET, POST, and cookies, so you lose track of where a value came from.

What is the difference between sanitizing and validating?

Sanitizing makes input SAFE to use — for example htmlspecialchars() escapes HTML so it can't run as a script. Validating checks input is CORRECT — for example filter_var(..., FILTER_VALIDATE_EMAIL) confirms a string is actually an email. They solve different problems, so you usually do both: validate that the data is right, and escape it whenever you output it. Escaping at output time (not input time) is the modern best practice, because the same value might be safe in HTML but dangerous in a URL or SQL query.

Why isn't client-side (JavaScript) validation enough?

Because the user controls the client. Anyone can disable JavaScript, edit the HTML, or send a request straight to your script with a tool like curl, bypassing your browser checks entirely. JavaScript validation is purely a UX nicety — it gives instant feedback. The server is the only place you actually control, so every rule that matters must be enforced again in PHP. Treat all incoming data as hostile until your server has checked it.

Do I really need a CSRF token on every form?

On any form that CHANGES state while a user is logged in — yes. Without a token, an attacker's page can silently make the user's browser submit your form (the browser helpfully attaches their session cookie), performing actions as them. A per-session random token that you embed in the form and verify with hash_equals() on submit blocks this, because the attacker can't read or guess the token. Read-only GET forms (like a search) generally don't need one, since they shouldn't change anything.

How do I validate an uploaded file's type safely?

Don't trust the filename extension or the browser-supplied MIME type in $_FILES['x']['type'] — both are easy to fake. Inspect the actual file contents instead: use finfo (finfo_file with FINFO_MIME_TYPE) on the temporary path, check the size, and only accept types on an explicit whitelist. Then move it out of the temp folder with move_uploaded_file() and save it under a name YOU generate (e.g. random bytes), never the user's original filename. Storing uploads outside your web root, or as non-executable files, adds another layer of safety.

Related lessons