Lesson 9 • Intermediate
Forms & User Input 📝
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.
What You'll Learn in This Lesson
- Read submitted data from $_GET, $_POST and $_REQUEST
- Sanitize input so it can't run as HTML or script (htmlspecialchars)
- Validate emails, integers and required fields with filter_var
- Re-display user values safely in a "sticky" form
- Accept file uploads via $_FILES and validate them
- Block forged submissions with a CSRF token
$_POST["name"] or foreach looks unfamiliar, revisit Introduction to PHP and the arrays lesson first.$_GET, $_POST and $_FILES when a form is submitted. PHP has no in-browser runner here, so each example sets those arrays in code — the sanitize/validate logic is identical to production. Paste any block into the free onecompiler.com/php (no install) or run php file.php locally. The Output panel under each example shows exactly what to expect.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.
echo "\n$_REQUEST also sees it: " . $_REQUEST["username"] . "\n";
?>From the URL (GET):
search term: php
page number: 2
From the form body (POST):
username: ada
$_REQUEST also sees it: ada2️⃣ 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 <script> — it can no longer execute.
?>Raw (dangerous): Nice post! <script>steal(cookies)</script>
Safe (escaped): Nice post! <script>steal(cookies)</script>See how <script> became <script> 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" => "alice@example.com",
"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";
}
}
?>All good! Welcome, Alice (28).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 " so it can't break out of
// the value="..." attribute. That's exactly why you escape.
?><input name="name" value="Bob "the builder"">
<input name="email" value="not-an-email">
<p class="error">Please enter a valid email.</p>The double quote in Bob "the builder" became ", 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";
echo "Valid upload — would save to: $newName\n";
// move_uploaded_file($file["tmp_name"], $newName); // (real code)
} else {
foreach ($errors as $e) {
echo " - $e\n";
}
}
?>Valid upload — would save to: uploads/a1b2c3d4e5f60718.pngNow 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" => " ADA@Example.com "];
// 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: ada@example.com
?>OK: ada@example.com___ with ?? and the second with FILTER_VALIDATE_EMAIL, then run it. Output should be OK: ada@example.com.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 <b>tip</b> <script>alert(1)</script></p>
?><p>Cool <b>tip</b> <script>alert(1)</script></p>___ with htmlspecialchars. The <script> should come out as <script>.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";
echo ' <input type="hidden" name="csrf" value="' . $token . '">' . "\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";
}
?>Form would include this hidden field:
<input type="hidden" name="csrf" value="<64 random hex chars>">
Token matches — request is genuine, process it.Common Errors (and the fix)
- Trusting user input — you used a value straight from
$_POSTin a query, a filename, or the page. All incoming data is attacker-controlled until you check it. Validate it (is it the right type/range?) and escape it for whatever context it's going into. "It works when I test it" is not the same as "it's safe". - XSS from un-escaped output — you echoed
$_POST["x"](or a stored value) into HTML withouthtmlspecialchars(), so a<script>a user submitted now runs for everyone. Escape at the point of output, every time — including insidevalue="..."attributes (useENT_QUOTES). - "Undefined array key 'name'" — you read
$_POST["name"]when the form didn't send it (or hadn't been submitted yet). Read with a fallback:$_POST["name"] ?? "", and gate processing behind$_SERVER["REQUEST_METHOD"] === "POST". - Missing or skipped validation — you relied on the browser's
required/type="email"attributes. Those are UX only; a user can disable JS or post directly with curl. Re-check every rule on the server. - CSRF token always fails / "Invalid token" — usually you forgot
session_start()on one of the pages, or compared with==. Callsession_start()on both the form and handler pages and compare withhash_equals().
Pro Tips
- 💡 Validate on input, escape on output. Check correctness when data arrives; escape it for the specific context (HTML, URL, SQL) at the moment you use it — the same value needs different escaping in different places.
- 💡 Always
exit;after a redirect.header("Location: /thanks.php"); exit;— withoutexitthe rest of the script keeps running. - 💡 Detect the real file type. Use
finfoontmp_nameinstead of trusting the browser-supplied$_FILES[...]["type"], which is trivial to fake.
📋 Quick Reference — Forms & Input
| Tool | Example | What 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) |
Frequently Asked Questions
Q: 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.
Q: 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.
Q: 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.
Q: 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.
Q: 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.
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" => "hi@site.com", "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
?>$errors array, escape the message, then print either the sent message or each error. The blank name means you should see exactly one error.Lesson Complete!
- ✅ Form data arrives in
$_GET(URL),$_POST(body), or$_REQUEST(both) — read with?? "" - ✅ Sanitize output with
htmlspecialchars()+ENT_QUOTESto stop XSS - ✅ Validate emails, ints and ranges with
filter_var(); require fields by checking they're not empty aftertrim() - ✅ Sticky forms re-display input — always escaped before it re-enters the HTML
- ✅ Uploads come via
$_FILES; check error/size/type, thenmove_uploaded_file()under a name you generate - ✅ A per-session CSRF token verified with
hash_equals()blocks forged submissions - ✅ Next lesson: Sessions & Cookies — remember a user across requests, the foundation of logins
Sign up for free to track which lessons you've completed and get learning reminders.