skip to content

In a PHP form handler, what problem does Post/Redirect/Get solve, and how do you redisplay validation errors with it?

level: middleimportance: must knowfreq 62%

answer

  1. refresh re-sends the POST
  2. 303 See Other, then exit
  3. success redirects, failure may re-render
  4. flash old input and errors in the session
  5. repopulate checked and selected state

basics

~20 s

Post/Redirect/Get stops a refresh or Back from resubmitting a POST: after processing, the handler answers with a 303 redirect to a GET page. Errors are shown by re-rendering the form directly, or by flashing old input and errors in the session.

solid answer

~40 s

If a POST handler renders HTML directly, the browser's current page **is** that POST, so refreshing it asks to resend the form and can save the survey twice. With **Post/Redirect/Get** the handler performs the side effect, then sends `Location` with status **303 See Other** (`header('Location: /survey/thanks', true, 303)`) and calls `exit`. The browser follows with a GET, and a refresh repeats only the GET. For validation failures there are two accepted shapes: re-render the form in the same POST response with the errors and the submitted values (no redirect; a resubmit is harmless because nothing was saved), or store `errors` and `old` input in `$_SESSION`, redirect back with 303, and read-then-`unset` them on the GET. Either way, repopulate from the old input: text values, `checked` for ticked boxes, `selected` for options, never passwords.

code

php · 21 lines
php
<?php
declare(strict_types=1);

session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $errors = validateSurvey($_POST);      // application function
    if ($errors === []) {
        saveSurvey($_POST);                // application function
        header('Location: /survey/thanks', true, 303);
        exit;
    }
    $_SESSION['flash'] = ['errors' => $errors, 'old' => $_POST];
    header('Location: /survey', true, 303);
    exit;
}

$flash = $_SESSION['flash'] ?? ['errors' => [], 'old' => []];
unset($_SESSION['flash']);
$ticked = $flash['old']['topics'] ?? [];
$parksChecked = in_array('parks', $ticked, true) ? 'checked' : '';

go deeper

for a junior

Recall that refreshing a page produced by a POST resends it, and that redirecting to a GET page after saving prevents that.

for a middle

Explain the full cycle with 303 and exit, and the two ways to show validation errors: re-render in the POST response or flash through the session.

for a senior

Cover what PRG does not fix, double clicks and concurrent submits, and how flashed old input repopulates checkbox groups and selects correctly.

for a principal

Set one form-handling convention across the codebase, covering redirect codes, flash storage and idempotency, so every form behaves the same.

## The problem: the page you are looking at is a POST A browser remembers how it obtained the current page. If a form handler processes a POST and then prints the result page itself, the page in history **is the POST request**. Three things can then resend it: - **Refresh** - the browser asks "confirm form resubmission" and, if the user agrees, posts the same body again; - **Back and forward** navigation onto that entry; - a **bookmark or shared link**, which cannot capture the POST at all. For a council survey that means duplicate responses; for a payment it means a second charge. ## The pattern **Post/Redirect/Get (PRG)** separates doing from showing: 1. The browser POSTs the form. 2. The handler validates and performs the side effect (insert the survey response). 3. Instead of HTML, it answers with a redirect to a GET URL and **stops**: `header('Location: /survey/thanks', true, 303); exit;`. 4. The browser follows the redirect with a GET and renders that page. Refresh now repeats only the harmless GET. **Why 303.** "See Other" tells the client to fetch the new location with GET regardless of the original method. Pass the code explicitly: without one, PHP chooses for you. The `header()` documentation promises 302, but the pinned source sends 303 for a non-GET request only when the server API reports HTTP/1.1 or later, and PHP-FPM and CGI report 1.0 internally, so they send 302. Browsers turn a 302 after POST into a GET in practice, but 303 states the intent. Always `exit` after the header: the script otherwise keeps running. ## Showing validation errors PRG is mandatory after a **successful** side effect. For a **failed** validation nothing was saved, so there are two accepted designs: | Approach | How | Trade-off | |---|---|---| | Re-render in the POST response | Build the form again with `$errors` and the submitted values | Simplest; refresh resubmits, but only invalid data that will fail again | | PRG with flash data | Put `errors` and `old` input in `$_SESSION`, 303 back to the form, read and `unset` on the GET | Clean history and URL; needs a session and one extra request | Flash data means: stored for exactly one following request. Read it, then remove it, so a later visit to the form starts clean. ## Repopulating the form Whichever approach you use, the user should not retype anything. Repopulate from the old input: - text inputs and textareas: the previous value, escaped for HTML; - a single checkbox: `checked` when the old value equals the one you rendered; - a checkbox group named `topics[]`: `checked` when `in_array($value, $old['topics'] ?? [], true)`; - `<select>` options: `selected` by the same comparison; - **never** passwords or card numbers, and never file inputs, which browsers cannot prefill. Remember that a checkbox group with nothing ticked has no key at all, so always default with `?? []` before `in_array()`. ## The full request sequence For one resident submitting the council survey with an error and then fixing it: 1. `GET /survey` - the form renders empty. 2. `POST /survey` - validation fails (no ward); the handler stores errors and old input in the session and answers `303` with `Location: /survey`. 3. `GET /survey` - the form renders with the error and the ticked topics restored; the flash entry is deleted. 4. `POST /survey` - validation passes; the response is saved and the handler answers `303` with `Location: /survey/thanks`. 5. `GET /survey/thanks` - refreshing this page repeats only this GET. At no point does the browser's history hold a POST that a refresh could replay. ## What PRG does not solve - A **double click** sends two POSTs before any redirect arrives. PRG does not stop that; a one-time form token or a unique constraint does. - It does not validate or authorize anything; it only changes what the browser keeps in history.

  • Why pass 303 explicitly instead of relying on header('Location: ...') alone?
    Without a code, PHP picks one: the pinned source sends 303 after a non-GET request only when the server API reports HTTP/1.1 or later, and PHP-FPM reports 1.0 internally, so it sends 302. Browsers usually switch to GET after a 302 anyway, but 303 See Other says exactly that and does not depend on the SAPI.
  • Does Post/Redirect/Get prevent duplicate submissions from a double click?
    No. A double click sends two POSTs before the first redirect reaches the browser, and both are processed. PRG only removes the POST from history so refresh and Back cannot replay it. Double submits need a one-time token checked on the server, a unique constraint, or an idempotent save.
  • Why must flashed form data be removed after it is read?
    Flash data is meant for exactly the next request. If it stays in `$_SESSION`, every later visit to the form shows stale errors and old values, possibly another submission's input. Read it into a local variable and `unset()` the session entry in the same request.

saying these in an interview costs you the question

  • Post/Redirect/Get also stops double-click duplicate submissions.
  • After header('Location: ...') the script stops running by itself.
  • A failed validation must always redirect, never re-render.
  • Flash data can stay in the session until the user logs out.
  • Repopulating the form should include the password field.