skip to content

In PHP, why must header('Location: ...') be followed by exit, and which status code does the redirect send?

level: juniorimportance: must knowfreq 66%

answer

  1. header() only queues a header
  2. code after it still runs
  3. 302 unless a 3xx or 201 is set
  4. third argument sets the status
  5. 303 after a POST save

basics

~20 s

header('Location: ...') only queues a header; the script keeps running, so code after it still executes and its output is sent. Call exit right after. PHP adds a 302 unless a code is given, so pass 303 or 301 explicitly.

solid answer

~40 s

`header()` adds a line to the pending headers and returns; it does not end the request. Without `exit`, everything after it still runs: a later `echo` still produces a body, a guard that redirects unauthorised users still renders the protected page for any client that ignores the redirect, and later code may even replace the `Location`. So write `header('Location: /admin/users', true, 303); exit;`. For the status, PHP adds a redirect code only if the current code is not already `201` or a `3xx`: by default `302`, and in the pinned source `303` for a non-GET request when the server API reports HTTP/1.1 or later (Apache's module does; PHP-FPM reports 1.0 internally and gets 302). Don't depend on that: pass the code as `header()`'s third argument, or call `http_response_code()` first.

code

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

if (!isAdmin()) {                          // application function
    header('Location: /login', true, 302);
    exit;                                    // without it, the page below still renders
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    saveUser($_POST);                        // application function
    header('Location: /admin/users', true, 303);
    exit;
}

require __DIR__ . '/templates/user-form.php';

go deeper

for a junior

Recall that header() does not stop the script, so a redirect is always followed by exit, and that the status can be passed as the third argument.

for a middle

Explain PHP's default code choice for Location, why it differs between server APIs, and when to choose 303, 302, 307, 301 or 308.

for a senior

Show how a missing exit turns an access guard into a data leak and how later code can override the redirect; review handlers for it.

for a principal

Standardise redirects in one helper or response object that always sets an explicit code and ends the request, so no handler can forget either.

## `header()` queues, it does not stop `header(string $header, bool $replace = true, int $response_code = 0): void` stores a header in the pending list. It returns immediately, and the script continues until it reaches the end or an `exit`. Nothing about a `Location` header ends the request. That has three practical consequences: - **Code after the redirect runs.** Database writes, logging, or a second `header('Location: ...')` that overrides the first all happen. - **Output after the redirect is sent.** The response carries the `Location` header *and* a body. Browsers follow the redirect and ignore the body, but a client that does not follow redirects sees everything. - **Access guards leak.** A check like "if not logged in, redirect to login" without `exit` goes on to render the admin page into the body of the redirect response. The fix is a habit: every redirect is immediately followed by `exit` (or a `return` from the front controller that ends the request). ## Which status code PHP sends When you set a `Location` header, PHP looks at the current response code: | Situation | Status sent | |---|---| | a `3xx` or `201` already set | unchanged | | third argument of `header()` given | that code | | otherwise, a GET or HEAD request | `302` | | otherwise, a non-GET request and the server API reports HTTP/1.1 or later | `303` | | otherwise | `302` | The manual's `header()` page mentions only the 302 case. The pinned source adds the 303 rule, and which branch applies depends on the server API: the Apache module passes the real protocol version, while PHP-FPM and CGI report HTTP/1.0 internally, so a redirect after a POST under FPM gets `302`. Browsers treat a 302 after a POST as "fetch the new URL with GET" in practice, but the result should not depend on the deployment. ## Choosing the code explicitly Pass the code yourself: - `303` - after a successful POST (Post/Redirect/Get): "see the result at this URL, with GET"; - `302` or `307` - a temporary redirect; `307` keeps the original method and body; - `301` or `308` - a permanent move; browsers and caches remember it, so use it only when you mean it. Two equivalent ways to set it: 1. `header('Location: /admin/users', true, 303);` - status and header in one call. 2. `http_response_code(303); header('Location: /admin/users');` - PHP keeps an existing 3xx. ## Reviewing code for missing exits Missing `exit` calls are easy to spot in review once you look for them: - search for every `header('Location` and check the next statement ends the request; - look at helper functions that redirect: if `redirect($url)` only calls `header()`, every caller must still `exit`, so make the helper end the request itself (and give it the return type `never`); - check guards at the top of scripts (authentication, permissions, CSRF failures), where a missing `exit` does the most damage; - check that nothing between the redirect and `exit` produces output or changes state. A static analyser can help here: a function declared as returning `never` tells both readers and tools that control does not come back. ## A complete admin-panel redirect After an admin saves a user record, the handler should: 1. perform the save; 2. make sure no output has been produced yet (otherwise the header is dropped); 3. set `Location` with an explicit `303`; 4. `exit`. One related detail: a `Location` header containing a newline is rejected with the warning "Header may not contain more than a single header, new line detected", which stops header injection through a crafted URL.

  • What exactly leaks if an access check redirects to the login page without exit?
    The script keeps running past the check and renders the protected page into the response body. A browser follows the `Location` header and never shows it, but any client that does not follow redirects, or a tool inspecting the raw response, receives the full page. Any state-changing code after the check also still runs.
  • Under PHP-FPM, which status does header('Location: /done') send after a POST if no code is given?
    `302`. The pinned source sends `303` for a non-GET request only when the server API reports HTTP/1.1 or later, and PHP-FPM reports HTTP/1.0 internally, so the default branch applies. Passing `303` as the third argument makes the result the same on every server API.

saying these in an interview costs you the question

  • header('Location: ...') ends the script like a return.
  • PHP always sends 301 for a Location header.
  • A body after a redirect is never sent to the client.
  • The default redirect status is the same under every server API.
  • A Location header ignores a 3xx code set earlier.