Handling Forms

PHP · Lesson 20 of 50 8 min read Updated

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:

  • action is the URL that receives the data. Leave it out and the form submits to the current page.
  • method is either get or post.
  • Each field's name becomes the key in $_GET or $_POST. A field without a name is not sent at all.
FeatureGETPOST
Where the data goesIn the URL: search.php?q=phpIn the request body
PHP array$_GET$_POST
Visible in history and logsYesNo
Can be bookmarked or sharedYesNo
Use it forSearching, filtering, pagingSaving, 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
<?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:

Output
<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:

  1. Set default values and an empty list of errors.
  2. If the request is a POST, read the fields, trim them and check them.
  3. If there are no errors, save the data and redirect.
  4. 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
<?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 if block 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. The required attribute 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.txt as 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:

Text
HTTP/1.1 303 See Other
Location: register.php?sent=1

Now 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; without exit, 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
<?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:

Output
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 name attribute. The field is silently left out of $_POST.
  • Showing a success page straight after a POST. Refreshing resubmits the form. Redirect with a 303 instead.
  • Output before header(). Any HTML, space or echo before 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 inside value="..." 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 name becomes a key in $_GET or $_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); and exit; (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
<?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?

2. What happens to a checkbox that the visitor leaves unchecked?

3. Why redirect with a 303 status after a successful POST?

4. What causes the warning "headers already sent"?

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