PHP Zero to Advanced #6: Forms, $_GET/$_POST, Validation & Escaping Output
Up to now our data has been hardcoded in the script. Real web applications receive data from users through forms, URLs, and APIs. User input is also the number one source of security bugs, so this part focuses on doing it right: reading $_GET and $_POST, validating every value, and escaping everything you output.
Golden rule: never trust user input. Validate on the way in, escape on the way out.
GET Requests and $_GET
A GET form puts its fields into the URL query string, for example search.php?q=php+book&sort=price. GET is appropriate for searching, filtering, and pagination: actions that only read data and can be bookmarked.
<!-- search.php -->
<form method="get" action="search.php">
<input type="text" name="q" placeholder="Search...">
<select name="sort">
<option value="newest">Newest</option>
<option value="price">Price</option>
</select>
<button type="submit">Search</button>
</form>
PHP parses the query string into the superglobal array $_GET. Fields may be missing, so always provide defaults with ??:
<?php
// Request: search.php?q=php+book&sort=price
$query = trim($_GET['q'] ?? '');
$sort = $_GET['sort'] ?? 'newest';
$page = (int) ($_GET['page'] ?? 1); // not provided, so default 1
echo "Query: $query\n";
echo "Sort: $sort\n";
echo "Page: $page\n";
Output:
Query: php book
Sort: price
Page: 1
POST Requests and $_POST
Use POST for actions that change data: registering, logging in, creating orders. Values travel in the request body instead of the URL. Field names ending with [] are received as arrays.
<!-- register.php -->
<form method="post" action="register.php">
<input type="text" name="name">
<input type="email" name="email">
<input type="number" name="age">
<label><input type="checkbox" name="interests[]" value="php"> PHP</label>
<label><input type="checkbox" name="interests[]" value="sql"> SQL</label>
<button type="submit">Register</button>
</form>
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$age = $_POST['age'] ?? '';
$interests = $_POST['interests'] ?? [];
var_dump($name, $age);
echo "Interests: " . implode(", ", $interests) . "\n";
}
Output:
string(12) "Budi Santoso"
string(2) "27"
Interests: php, sql
Notice two important facts in the output: the name still contains the spaces until you trim() it, and the age is a string ("27"), not an int. Everything that arrives over HTTP is a string (or an array of strings).
Check $_SERVER['REQUEST_METHOD'] to know whether the form was submitted. Avoid $_REQUEST, which mixes GET, POST, and cookies and makes it unclear where data came from.
Validating Input
filter_var() validates common formats. It returns the (converted) value on success and false on failure, so compare with === false:
<?php
var_dump(filter_var("budi@example.com", FILTER_VALIDATE_EMAIL));
var_dump(filter_var("budi@", FILTER_VALIDATE_EMAIL));
var_dump(filter_var("42", FILTER_VALIDATE_INT));
var_dump(filter_var("4.2", FILTER_VALIDATE_INT));
var_dump(filter_var("150", FILTER_VALIDATE_INT, [
"options" => ["min_range" => 1, "max_range" => 120],
]));
var_dump(filter_var("https://example.com", FILTER_VALIDATE_URL));
var_dump(filter_var("yes", FILTER_VALIDATE_BOOLEAN));
Output:
string(16) "budi@example.com"
bool(false)
int(42)
bool(false)
bool(false)
string(19) "https://example.com"
bool(true)
Note that FILTER_VALIDATE_INT returns a real int, which conveniently converts the string for you. In practice, put validation into a function that returns cleaned data and an array of error messages:
<?php
declare(strict_types=1);
function validateRegistration(array $input): array
{
$errors = [];
$name = trim($input['name'] ?? '');
if ($name === '') {
$errors['name'] = 'Name is required.';
} elseif (mb_strlen($name) > 50) {
$errors['name'] = 'Name must be at most 50 characters.';
}
$email = trim($input['email'] ?? '');
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Please enter a valid email address.';
}
$age = filter_var($input['age'] ?? '', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 13, 'max_range' => 120],
]);
if ($age === false) {
$errors['age'] = 'Age must be a number between 13 and 120.';
}
$allowed = ['php', 'sql', 'js'];
$interests = array_values(array_intersect($input['interests'] ?? [], $allowed));
return [
'data' => compact('name', 'email', 'age', 'interests'),
'errors' => $errors,
];
}
$result = validateRegistration([
'name' => '',
'email' => 'siti@example',
'age' => '9',
'interests' => ['php', 'hacking'],
]);
print_r($result['errors']);
print_r($result['data']['interests']);
Output:
Array
(
[name] => Name is required.
[email] => Please enter a valid email address.
[age] => Age must be a number between 13 and 120.
)
Array
(
[0] => php
)
Note the allow-list for interests: instead of trying to block bad values, we only accept the values we know. The unknown value hacking was silently dropped.
Escaping Output to Prevent XSS
Cross-Site Scripting (XSS) happens when user input is printed into HTML without escaping, letting an attacker inject JavaScript that runs in other users' browsers. The fix is htmlspecialchars(), which converts special characters into HTML entities:
<?php
// Request: hello.php?name=<script>alert("hacked")</script>
$name = $_GET['name'] ?? 'Guest';
echo "<p>Hello, " . $name . "</p>\n"; // DANGEROUS: script runs in the browser
echo "<p>Hello, " . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . "</p>\n";
Output:
<p>Hello, <script>alert("hacked")</script></p>
<p>Hello, <script>alert("hacked")</script></p>
The second line is displayed as harmless text. Since you will escape constantly, create a short helper. The convention is a function named e():
<?php
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$comment = "Tom & Jerry's \"best\" <episode>";
echo e($comment) . "\n";
$url = "https://example.com/search?q=" . urlencode("php & sql");
echo '<a href="' . e($url) . '">Search</a>' . "\n";
Output:
Tom & Jerry's "best" <episode>
<a href="https://example.com/search?q=php+%26+sql">Search</a>
Escape at the moment of output, not when saving to the database. Data might be used in HTML, JSON, or an email, and each context needs its own escaping. Also note urlencode() for values placed in URLs.
Putting It Together
Here is a complete registration page. It re-displays the form with errors and the user's previous values (escaped!), and follows the Post/Redirect/Get pattern: after a successful POST it redirects, so refreshing the page does not resubmit the form.
<?php
// register.php - a complete form with validation and safe output
declare(strict_types=1);
require __DIR__ . '/functions.php'; // contains e() and validateRegistration()
$errors = [];
$old = ['name' => '', 'email' => '', 'age' => ''];
$success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = validateRegistration($_POST);
$errors = $result['errors'];
$old = array_merge($old, array_intersect_key($_POST, $old));
if ($errors === []) {
// Save to database (Part 9), then redirect (Post/Redirect/Get)
header('Location: /register.php?ok=1');
exit;
}
}
$success = isset($_GET['ok']);
?>
<!DOCTYPE html>
<html>
<body>
<?php if ($success): ?>
<p>Registration successful!</p>
<?php endif; ?>
<form method="post">
<input name="name" value="<?= e($old['name']) ?>">
<?php if (isset($errors['name'])): ?>
<small><?= e($errors['name']) ?></small>
<?php endif; ?>
<input name="email" type="email" value="<?= e($old['email']) ?>">
<?php if (isset($errors['email'])): ?>
<small><?= e($errors['email']) ?></small>
<?php endif; ?>
<input name="age" type="number" value="<?= e($old['age']) ?>">
<button type="submit">Register</button>
</form>
</body>
</html>
The alternative syntax if (...): / endif; is common in templates because it is easier to read between HTML tags.
Common Mistakes
- Echoing input without
htmlspecialchars. This is the most common XSS vulnerability. Escape every dynamic value in HTML, including attribute values. - Relying only on HTML5 validation like
requiredortype="email". Attackers can send requests directly with tools like curl, bypassing the browser. Always validate on the server. - Using
if ($_POST['name'])without checking existence. A missing key throws a warning and"0"is treated as empty. Use?? ''and explicit checks. - Using GET for actions that change data. Links can be prefetched, cached, or triggered by other sites. Use POST plus a CSRF token (covered in Part 10).
- Forgetting
exitafterheader('Location: ...'). The rest of the script keeps running.
Exercise
Build an order page that reads name, product, and qty from the query string. Validate the name is present, the product exists in an allow-list, and the quantity is between 1 and 10. Escape everything you print.
<?php
// Request: order.php?name=Siti+<b>&qty=3&product=mug
function e(?string $v): string
{
return htmlspecialchars($v ?? '', ENT_QUOTES, 'UTF-8');
}
$products = ['mug' => 45000, 'shirt' => 120000];
$name = trim($_GET['name'] ?? '');
$product = $_GET['product'] ?? '';
$qty = filter_var($_GET['qty'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10]]);
$errors = [];
if ($name === '') $errors[] = 'Name is required';
if (!array_key_exists($product, $products)) $errors[] = 'Unknown product';
if ($qty === false) $errors[] = 'Quantity must be 1-10';
if ($errors) {
echo implode("\n", array_map('e', $errors)) . "\n";
} else {
$total = $products[$product] * $qty;
echo "Thanks " . e($name) . ", total: Rp " . number_format($total, 0, ',', '.') . "\n";
}
Output:
Thanks Siti <b>, total: Rp 135.000
Try requesting it with qty=50 or product=laptop and check that the right error messages appear.
Summary
$_GETholds query string values;$_POSTholds form body values. Both contain only strings and arrays.- Always use defaults (
??), trim, and validate withfilter_varand allow-lists. - Escape every output with
htmlspecialchars($v, ENT_QUOTES, 'UTF-8')to prevent XSS. - Use the Post/Redirect/Get pattern to avoid duplicate submissions.
In Part 7 we will organize our code with object-oriented programming: classes, constructor property promotion, interfaces, traits, and enums.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
Ruby on Rails Zero to Advanced #5: Routing, Controllers & Views
Follow a request through Rails: define routes, use resources, read params in controllers, and render dynamic HTML with ERB views and partials.
Python Zero to Advanced #5: Lists, Tuples, Dictionaries, Sets & Comprehensions
Master Python's built-in collections: lists, tuples, dictionaries, and sets, when to use each, slicing, unpacking, and writing concise list, dict, and set comprehensions.