Handling Forms
Forms are how visitors talk to your website: they search, sign up, place orders and send messages. In this lesson you will connect HTML forms to PHP and build two complete, working forms in single PHP files.
You will build a GET search form that remembers what was typed, then a POST registration form that checks its input, saves it and uses the Post/Redirect/Get pattern so that refreshing the page never submits it twice. You should already know Superglobals and the basics of HTML Forms.
How a form sends data
Three attributes decide where and how a form sends its data:
actionis the URL that receives the data. Leave it out and the form submits to the current page.methodis eithergetorpost.- Each field's
namebecomes the key in$_GETor$_POST. A field without anameis not sent at all.
| Feature | GET | POST |
|---|---|---|
| Where the data goes | In the URL: search.php?q=php | In the request body |
| PHP array | $_GET | $_POST |
| Visible in history and logs | Yes | No |
| Can be bookmarked or shared | Yes | No |
| Use it for | Searching, filtering, paging | Saving, sending, deleting, logging in |
The rule of thumb: if submitting the form changes something on the server, use POST. If it only reads something, use GET.
A GET search form
This single file shows a search box and the results. Notice that the box keeps what the visitor typed, so they can correct their search:
<?php
declare(strict_types=1);
$books = ['Learning PHP', 'Python for Beginners', 'PHP and MySQL Projects', 'Web Design Basics'];
$query = $_GET['q'] ?? '';
$query = is_string($query) ? trim($query) : '';
$results = [];
if ($query !== '') {
$needle = mb_strtolower($query);
$results = array_filter(
$books,
fn(string $title): bool => str_contains(mb_strtolower($title), $needle)
);
}
?>
<form method="get" action="search.php">
<label for="q">Search books</label>
<input id="q" name="q" type="search" value="<?= htmlspecialchars($query) ?>">
<button>Search</button>
</form>
<?php if ($query !== ''): ?>
<p><?= count($results) ?> result(s) for "<?= htmlspecialchars($query) ?>"</p>
<ul>
<?php foreach ($results as $title): ?>
<li><?= htmlspecialchars($title) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>When a visitor types php and presses Search, the browser opens search.php?q=php and receives this HTML:
<form method="get" action="search.php"> <label for="q">Search books</label> <input id="q" name="q" type="search" value="php"> <button>Search</button> </form> <p>2 result(s) for "php"</p> <ul> <li>Learning PHP</li> <li>PHP and MySQL Projects</li> </ul>
Two details make this safe. The is_string() check handles a URL like ?q[]=x, which would make $_GET['q'] an array. And every value printed into the page, including the one inside the value="..." attribute, goes through htmlspecialchars(). Without it, a search for "><script> would break out of the attribute and inject code. In a real application the books would come from a database query, as you will see in CRUD with Prepared Statements.
A POST form, step by step
A POST form usually follows the same shape in one file:
- Set default values and an empty list of errors.
- If the request is a POST, read the fields, trim them and check them.
- If there are no errors, save the data and redirect.
- Otherwise, fall through and show the form again, with the errors and the values the visitor typed.
Here is a registration form for a cricket club's trials, saved as register.php:
<?php
declare(strict_types=1);
const ROLES = ['batter' => 'Batter', 'bowler' => 'Bowler', 'all-rounder' => 'All-rounder'];
function postString(string $key): string
{
$value = $_POST[$key] ?? '';
return is_string($value) ? trim($value) : '';
}
$name = '';
$role = 'batter';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = postString('name');
$role = postString('role');
if ($name === '') {
$errors[] = 'Please enter your name.';
}
if (!array_key_exists($role, ROLES)) {
$errors[] = 'Please choose a role from the list.';
}
if ($errors === []) {
$record = json_encode(['name' => $name, 'role' => $role]);
file_put_contents(__DIR__ . '/trials.txt', $record . "\n", FILE_APPEND | LOCK_EX);
header('Location: register.php?sent=1', true, 303);
exit;
}
}
$sent = ($_GET['sent'] ?? '') === '1';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cricket Trials</title>
</head>
<body>
<h1>Register for the cricket trials</h1>
<?php if ($sent): ?>
<p>Thank you! See you at the ground on Sunday.</p>
<?php endif; ?>
<?php foreach ($errors as $error): ?>
<p class="error"><?= htmlspecialchars($error) ?></p>
<?php endforeach; ?>
<form method="post" action="register.php">
<label>Name <input name="name" value="<?= htmlspecialchars($name) ?>" required></label>
<label>Role
<select name="role">
<?php foreach (ROLES as $value => $label): ?>
<option value="<?= $value ?>"<?= $value === $role ? ' selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</label>
<button>Register</button>
</form>
</body>
</html>Walk through what happens:
- First visit (GET). The
ifblock is skipped. The page shows an empty form with "Batter" selected. - Submit with mistakes. Suppose the name is blank and someone has edited the page to send the role
umpire. Both checks fail, so the page shows "Please enter your name." and "Please choose a role from the list." above the form. Therequiredattribute normally stops a blank name in the browser, but the server check still matters, because requests can be sent without your form. - Submit correctly. The record is added to
trials.txtas one line of JSON, and PHP sends a redirect instead of a page.
The postString() helper reads one field, treats anything that is not a string as empty, and trims spaces. The role is checked against the keys of ROLES, so only the three real options are accepted, whatever the browser sends. file_put_contents() with FILE_APPEND | LOCK_EX adds a line safely; you will learn more in Reading & Writing Files.
Post/Redirect/Get
Imagine the script showed "Thank you" directly after saving. If the visitor pressed refresh, the browser would offer to send the form again, and you would get a duplicate registration, or worse, a duplicate payment.
The Post/Redirect/Get (PRG) pattern prevents this. After a successful POST, the server does not show a page. It sends a redirect response, and the browser follows it with a fresh GET request:
HTTP/1.1 303 See Other
Location: register.php?sent=1Now the page in the browser came from a GET request. Refreshing it just shows the thank-you message again; nothing is saved twice. The status 303 See Other tells the browser to use GET for the next request.
Three rules for header() redirects:
- Call
header()before any output. Even one space or blank line printed earlier causes the warning "Cannot modify header information - headers already sent". This is another reason to leave out the closing?>in pure PHP files. - Always call
exit;straight after the redirect.header()only adds a header; withoutexit, the rest of the script keeps running. - Redirect only to your own fixed URLs, never to an address taken from user input.
The ?sent=1 in the URL is a simple way to show a message after the redirect. For a message that should appear only once, store it in the session instead, as shown in Sessions & Cookies.
Checkboxes and multiple values
Two kinds of fields need extra care:
- An unchecked checkbox is not sent at all. Check it with
isset($_POST['agree']), not by reading its value. - To send several values under one name, add square brackets to the name:
name="days[]". PHP then gives you an array.
<?php
declare(strict_types=1);
const PRACTICE_DAYS = ['mon', 'wed', 'fri', 'sun'];
$days = $_POST['days'] ?? [];
if (!is_array($days)) {
$days = [];
}
$days = array_values(array_filter(
$days,
fn(mixed $day): bool => in_array($day, PRACTICE_DAYS, true)
));
echo $days === [] ? 'No days chosen' : 'Practice on: ' . implode(', ', $days);If the form sends days[]=mon, days[]=fri and a forged days[]=hack, the page shows:
Practice on: mon, fri
The forged value is dropped because it is not in the allowed list. The same idea works for radio buttons and <select> fields: always compare the submitted value with the options you offered.
Common mistakes
- Forgetting the
nameattribute. The field is silently left out of$_POST. - Showing a success page straight after a POST. Refreshing resubmits the form. Redirect with a
303instead. - Output before
header(). Any HTML, space orechobefore a redirect causes "headers already sent". - Leaving out
exit;after a redirect. The script carries on and may save or print more than you expect. - Printing submitted values without
htmlspecialchars(). Re-shown values must be escaped, especially insidevalue="..."attributes. - Trusting
<select>or radio values. Visitors can send any value. Check it against your list.
Key points
- Use GET for forms that only read data and POST for forms that change data.
- Each field's
namebecomes a key in$_GETor$_POST; unnamed fields and unchecked checkboxes are not sent. - Check
$_SERVER['REQUEST_METHOD'] === 'POST'before processing a POST form. - Re-show the visitor's values on error, always escaped with
htmlspecialchars(). - After a successful POST, redirect with
header('Location: ...', true, 303);andexit;(Post/Redirect/Get). name="items[]"sends an array; validate every value against the options you offered.
Exercise
Build feedback.php for a college canteen. The form uses POST and has a name text field and a rating select with the options 1 to 5. On submit, trim the name, require it, and accept the rating only if it is one of '1' to '5'. On success, append one JSON line to feedback.txt and redirect to feedback.php?thanks=1 with a 303 status. On error, show the messages and keep the typed name in the box.
Show solution
<?php
declare(strict_types=1);
$name = '';
$rating = '5';
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = is_string($_POST['name'] ?? null) ? trim($_POST['name']) : '';
$rating = is_string($_POST['rating'] ?? null) ? $_POST['rating'] : '';
if ($name === '') {
$errors[] = 'Please tell us your name.';
}
if (!in_array($rating, ['1', '2', '3', '4', '5'], true)) {
$errors[] = 'Please choose a rating from 1 to 5.';
}
if ($errors === []) {
$line = json_encode(['name' => $name, 'rating' => (int) $rating]) . "\n";
file_put_contents(__DIR__ . '/feedback.txt', $line, FILE_APPEND | LOCK_EX);
header('Location: feedback.php?thanks=1', true, 303);
exit;
}
}
?>
<?php if (($_GET['thanks'] ?? '') === '1'): ?>
<p>Thanks for your feedback!</p>
<?php endif; ?>
<?php foreach ($errors as $error): ?>
<p><?= htmlspecialchars($error) ?></p>
<?php endforeach; ?>
<form method="post" action="feedback.php">
<label>Name <input name="name" value="<?= htmlspecialchars($name) ?>"></label>
<label>Rating
<select name="rating">
<?php foreach (['1', '2', '3', '4', '5'] as $option): ?>
<option<?= $option === $rating ? ' selected' : '' ?>><?= $option ?></option>
<?php endforeach; ?>
</select>
</label>
<button>Send</button>
</form>The rating is compared strictly against the five strings the form offers, so a forged value like 10 is rejected. The redirect happens before any HTML is printed, and exit stops the script straight after it.
Quick Quiz
1. Which method should a "place order" form use?
Forms that save or change data should use POST; GET is for reading.
2. What happens to a checkbox that the visitor leaves unchecked?
Unchecked checkboxes are left out of the request, so check them with isset().
3. Why redirect with a 303 status after a successful POST?
Post/Redirect/Get turns the final page into a GET request, so a refresh cannot repeat the POST.
4. What causes the warning "headers already sent"?
Headers must be sent before the page body, so any earlier output, even a space, breaks header().
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
