Post/Redirect/Get in PHP - Make Refresh Safe Without Pretending POST Is Idempotent
A form saves a new record, the browser shows a success page, and then someone presses refresh. Should that refresh display the result again, or should it send the original form a second time? The familiar browser warning about resubmitting data reveals an architectural detail: the page in the address bar is still the response to a state-changing request.
Post/Redirect/Get, usually shortened to PRG, gives that interaction a cleaner shape. After accepting a POST, the server redirects the browser to a resource that can be retrieved with GET. Refresh then repeats the retrieval, not the form submission. That is useful, but its promise is narrower than the phrase “prevent duplicate submissions” sometimes suggests. PRG changes the browser's next navigation; it does not make the original write happen only once.
The pattern is a three-message conversation
Consider a form that creates a note. Without a redirect, the browser sends POST /notes and receives the success page directly. The current document is therefore associated with the POST response. Reloading it can require sending the POST body again.
With PRG, the exchange has three steps:
- The browser sends
POST /noteswith the form data. - After the server commits the new note, it responds with
303 See OtherandLocation: /notes/42. - The browser follows that location with
GET /notes/42and renders the result.
POST /notes HTTP/1.1
Host: example.test
Content-Type: application/x-www-form-urlencoded
title=A%20small%20question
HTTP/1.1 303 See Other
Location: /notes/42
Content-Length: 0
GET /notes/42 HTTP/1.1
Host: example.test
The final page now represents a GET. It can normally be refreshed, linked, or bookmarked without repeating the create operation. RFC 9110 defines 303 specifically as a redirect to another resource whose representation can be retrieved with GET or HEAD. The MDN 303 reference shows the same form-submission use case.
This is not merely a cosmetic URL change. It separates the command from the representation of its result. The POST says “try to create this note.” The later GET says “show note 42.” Those requests have different purposes and can have different caching, authorization, and error behavior.
Why 303 is clearer than an accidental 302
Many applications use 302 Found after a form and appear to work. That behavior has a historical basis: RFC 9110 permits a user agent to change POST to GET when following a 302. It is not necessary to declare every existing 302 flow broken.
However, 303 See Other states the intended transition directly. The next request retrieves the selected resource rather than repeating the original operation. A reader of the route, a test, or an HTTP trace does not have to infer that the application depends on the historical POST-to-GET behavior of 302.
307 Temporary Redirect means something different. Its defining property is that the user agent must not change the request method while following the redirect. A POST redirected with 307 remains a POST. That is useful when an endpoint has temporarily moved and the same operation must reach another URI, but it is the opposite of the usual PRG goal. The corresponding permanent method-preserving status is 308.
The status code should therefore follow the intended semantics, not a rule that one redirect is universally better:
- Use
303when a completed POST should lead to a GET representation. - Use
307or308when the redirected request must preserve its method and body. - Treat
302as a temporary redirect with historical method-changing behavior, not as the clearest way to describe PRG.
A deliberately small PHP example
The following handler illustrates the success boundary. It assumes that authentication, authorization, CSRF validation, input length limits, and the PDO connection already exist. It also assumes that notes.id is an auto-incrementing integer. Those omitted controls are part of the surrounding application, not optional production details.
<?php
declare(strict_types=1);
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '') {
http_response_code(422);
renderNoteForm(['title' => 'A title is required.'], $_POST);
exit;
}
$statement = $pdo->prepare(
'INSERT INTO notes (title) VALUES (:title)'
);
$statement->execute(['title' => $title]);
$noteId = (int) $pdo->lastInsertId();
header('Location: /notes/' . $noteId, true, 303);
exit;
The redirect occurs only after the insert succeeds. If validation fails, no note exists to show, so the handler renders a useful error instead of pretending that the command completed. If the database write throws an exception, normal error handling should take over; sending a success redirect would create a false confirmation.
The official PHP documentation for header() records three relevant details. Headers must be scheduled before response output, a bare Location normally causes a 302 unless a suitable status was already set, and the third argument can set the response code. The immediate exit is equally deliberate: redirect headers do not terminate the PHP script by themselves.
A success message can be shown on the GET page directly from the created resource, or carried as short-lived session “flash” state. Flash state is a presentation concern; the created record and its identifier remain the durable result. A message that disappears on refresh should not be the only evidence that an operation succeeded.
What PRG does not prevent
Imagine that someone double-clicks the submit button quickly enough to send two POST requests. Both requests can reach the server before either response is received, so neither 303 has had a chance to influence browser navigation. Two tabs can do the same thing. A script can ignore the redirect. A connection can fail after the server commits but before the client receives the response, leaving the client uncertain about the outcome.
None of these cases contradicts PRG. They occur at a different boundary. The pattern controls what a user agent should do after one POST response; it does not merge two POST requests into one operation.
This distinction follows the HTTP model. RFC 9110 defines idempotency in terms of the intended effect of multiple identical requests. PUT and DELETE are defined as idempotent methods; POST is not. An application can design a particular POST operation to tolerate repetition, but a redirect after it does not supply that property.
Disabling a submit button with JavaScript can reduce accidental double-clicks and communicate that work is in progress. It is worthwhile user-interface feedback, but it cannot be the correctness boundary. JavaScript can fail, requests can come from another client, and two requests may already be in flight before the button changes.
Put the duplicate boundary where the business rule lives
The server first needs a precise answer to “duplicate in what sense?” Two newsletter subscriptions for the same list and email address may represent one membership. Two comments with identical text may be intentional. Two payment attempts require a stronger, operation-specific contract than comparing their amounts.
Several controls fit different rules:
- A unique database constraint fits a natural invariant such as one membership per
(list_id, subscriber_id). MariaDB documents that aUNIQUEconstraint requires the constrained value or combination to occur only once and rejects a violation. This is stronger under concurrency than checking for a row and inserting later as two unrelated steps. - An operation token or idempotency key fits a create action that has no natural unique value. The form receives an unpredictable operation identifier, and the server records that identifier with the result. Reuse returns or refers to the original outcome instead of applying the effect again. The token must have a defined scope and lifetime, and claiming it atomically with the write is essential; a separate “have I seen this?” lookup can itself race.
- A transaction keeps the accepted operation and its duplicate marker together when several database changes form one decision. It does not make external side effects exactly once. Email, payment APIs, and queues still need their own retry and reconciliation contracts.
These controls complement PRG. The server protects the operation while the redirect gives the browser a stable result page. One is a data-integrity boundary; the other is a navigation boundary.
CSRF tokens answer another question
A CSRF token is sometimes mistaken for a general one-time submission token. Its security purpose is different: it helps a cookie-authenticated application reject a state-changing request forged from an untrusted context. The OWASP CSRF guidance recommends backend validation of CSRF defenses for state-changing requests and warns against using GET for state changes.
A valid CSRF token does not prove that the same authorized user has not submitted the operation twice. Conversely, a duplicate-operation key does not prove that the request came from an allowed origin or user interaction. Keeping the concepts separate makes review easier, even if an application stores both pieces of state in the same session or form.
Test the sequence and the race
A browser click alone is not enough to verify PRG. Inspect the network exchange or use a disposable test environment to check at least these cases:
- A valid POST returns
303with the expected trustedLocation. - Following the redirect uses GET, and refreshing the result does not send another POST.
- Invalid input creates no record and preserves useful form errors.
- A failed write sends no success redirect.
- Two concurrent requests that represent the same protected operation produce one accepted business result.
- Reusing an operation key returns a defined result rather than silently applying the write again.
- Missing or invalid CSRF evidence is rejected independently of duplicate handling.
The concurrent case matters most. Sequentially clicking twice after each page finishes does not exercise the race that a storage constraint or atomic token claim is meant to resolve.
Conclusion
Post/Redirect/Get solves a real but specific problem. It turns the page after a successful form POST into a GET resource, so ordinary refresh and bookmarking no longer point at the state-changing request. A 303 makes that transition explicit, and PHP can send it with a small, readable response.
The honest boundary is just as important as the pattern. PRG cannot stop two POST requests that already exist, decide what counts as the same business operation, or authenticate the user's intent. Those jobs belong to application rules, database constraints or operation keys, transactions where appropriate, and CSRF defenses. A robust form flow uses these layers together: protect the write on the server, then redirect the browser to a representation that is safe to revisit.
References
- Fielding, R., Nottingham, M., and Reschke, J., eds. RFC 9110: HTTP Semantics. IETF / RFC Editor, June 2022.
- WHATWG. HTML Standard: Form submission algorithm. Living Standard, accessed October 9, 2026.
- PHP Documentation Group. PHP Manual:
header. Accessed October 9, 2026. - MariaDB. MariaDB Server Documentation: CONSTRAINT. Accessed October 9, 2026.
- MDN contributors. 303 See Other. Last modified June 22, 2026.
- OWASP Cheat Sheet Series. Cross-Site Request Forgery Prevention Cheat Sheet. Accessed October 9, 2026.
