HTTP Status Codes: 1xx Through 5xx
Recognize every commonly used HTTP status code grouped by class (1xx informational, 2xx success, 3xx redirection, 4xx client error, 5xx server error) and know when to return each from PHP using http_response_code().
The status code is the single most important piece of metadata in an HTTP response: it tells the client, in three digits, what actually happened, independent of whatever is in the body. Getting status codes right is what makes an API predictable to consume — clients, caches, and monitoring tools all branch on the status code first. This lesson works through every code called out in the spec: 200, 201, 202, 204, 301, 302, 304, 307, 308, 400, 401, 403, 404, 405, 409, 412, 413, 415, 422, 429, 500, 502, 503, and 504, grouped by class.
1xx and 2xx — informational and success
1xx codes are informational, sent before the final response completes (e.g. 100 Continue, telling a client it's fine to send a large body it was about to hold back). PHP web developers rarely emit these manually; the web server/SAPI handles them.
2xx codes mean the request was successfully received, understood, and accepted. 200 OK is the general-purpose success code, used for a successful GET or a successful PUT/PATCH/DELETE that returns a body. 201 Created is returned specifically after a successful POST that created a new resource, typically paired with a Location header pointing at the new resource's URL. 202 Accepted means the request was accepted for processing but that processing isn't finished yet — common for async/queued work (e.g. 'your report is being generated'). 204 No Content means success with deliberately no response body — the conventional response to a successful DELETE, or a successful PUT/PATCH where there's nothing useful to send back.
• 200 OK — general success, body included • 201 Created — success from POST creating a new resource; pair with Location header • 202 Accepted — accepted for async/background processing, not yet complete • 204 No Content — success, deliberately empty body (typical for DELETE)
3xx — redirection
3xx codes tell the client the resource is available at a different URL, given in the Location header. 301 Moved Permanently means the resource's URL has changed for good — clients and search engines should update their stored links to the new URL. 302 Found means a temporary redirect — the original URL is still valid long-term, this is just a one-off redirect right now (classically used after a form POST, though 303 is the technically more correct code for that case in modern usage). 304 Not Modified is a caching response: it tells the client 'the cached copy you already have is still valid, I'm not sending the body again' — returned when a conditional GET (with If-None-Match or If-Modified-Since) matches the current version.
307 Temporary Redirect and 308 Permanent Redirect are the modern, stricter counterparts to 302 and 301: unlike 301/302, which historically get re-sent as a GET even if the original request was a POST (a long-standing browser quirk), 307 and 308 explicitly guarantee the method and body are preserved on the redirected request — use 307/308 when redirecting a POST/PUT and you need the client to repeat it exactly rather than silently downgrading to GET.
• 301 Moved Permanently — URL changed for good, update bookmarks/links • 302 Found — temporary redirect, original URL stays valid • 304 Not Modified — cached copy is still valid, no body sent • 307 Temporary Redirect — like 302 but guarantees method/body are preserved • 308 Permanent Redirect — like 301 but guarantees method/body are preserved
4xx — client errors
4xx codes mean the request itself is the problem. 400 Bad Request is the generic 'this request is malformed and I can't understand it' (bad syntax, unparseable JSON). 401 Unauthorized actually means 'unauthenticated' — the client hasn't proven who they are (missing/invalid credentials); paired conventionally with a WWW-Authenticate header. 403 Forbidden means the client IS understood (and may even be authenticated) but is not allowed to perform this action — the server understood exactly who's asking and is refusing anyway. 404 Not Found means no resource exists at this URL. 405 Method Not Allowed means the resource exists but doesn't support the method used (pair with an Allow header, as covered in the methods lesson).
409 Conflict means the request conflicts with the current state of the resource (e.g. trying to create a username that already exists). 412 Precondition Failed means a conditional request's precondition (an If-Match or If-Unmodified-Since header) didn't hold — used for optimistic concurrency control, refusing to overwrite a resource someone else already changed. 413 Payload Too Large means the request body exceeds a size limit the server enforces. 415 Unsupported Media Type means the request body's Content-Type isn't one the endpoint can handle. 422 Unprocessable Entity means the request was well-formed and understood, but its content fails semantic validation (e.g. valid JSON, but 'email' isn't a valid email address) — this is the standard code for form/API validation failures. 429 Too Many Requests means the client has been rate-limited; conventionally paired with a Retry-After header telling it when to try again.
• 400 Bad Request — malformed/unparseable request • 401 Unauthorized — not authenticated (misnamed historically; really means 'unauthenticated') • 403 Forbidden — authenticated (or not) but not permitted to do this • 404 Not Found — no such resource • 405 Method Not Allowed — resource exists, method not supported here • 409 Conflict — request conflicts with current resource state • 412 Precondition Failed — a conditional header's precondition didn't hold • 413 Payload Too Large — request body exceeds allowed size • 415 Unsupported Media Type — Content-Type not handled by this endpoint • 422 Unprocessable Entity — well-formed but semantically invalid (validation errors) • 429 Too Many Requests — client is rate-limited; pair with Retry-After
5xx — server errors
5xx codes mean the server itself failed to fulfill a request it accepted as valid — the fault is on the server side, not the client's. 500 Internal Server Error is the generic catch-all for an unhandled exception or unexpected failure. 502 Bad Gateway means a server acting as a gateway or proxy (like nginx in front of PHP-FPM) got an invalid response from the upstream server it was talking to. 503 Service Unavailable means the server is temporarily unable to handle the request (overloaded, or deliberately down for maintenance) — often paired with a Retry-After header. 504 Gateway Timeout means a gateway/proxy timed out waiting for the upstream server (e.g. PHP-FPM) to respond in time.
• 500 Internal Server Error — generic unhandled server-side failure • 502 Bad Gateway — proxy got an invalid response from upstream • 503 Service Unavailable — server temporarily can't handle requests (overload/maintenance) • 504 Gateway Timeout — proxy's upstream (e.g. PHP-FPM) didn't respond in time
Setting a status code in PHP
http_response_code() is the modern, preferred way to set the status — it doesn't depend on knowing the exact reason phrase text and works regardless of call order relative to header(). Whatever the status code, it must be set before any output is sent, exactly like any other header.
<?php
// http_response_code() with no argument returns the current code
$current = http_response_code(); // e.g. int(200) — the default
// Pass an int to SET the status code for this response
http_response_code(201);
header('Location: /orders/501');
echo json_encode(['id' => 501, 'status' => 'created']);
// Alternative (older / lower-level) way, must be the FIRST header call:
// header('HTTP/1.1 404 Not Found');Code Examples
The simplest possible pattern: check a condition, set the matching status code, echo a body describing the outcome. This scales up directly into real controller logic. Expected result: {"error":"User not found"}
This shows 422 for a semantic validation failure (missing required field), 409 for a state conflict (username already taken), and 201 with a Location header for a successful creation — three of the most commonly confused 4xx/2xx codes used correctly side by side.
The GET branch implements conditional caching: if the client's If-None-Match header matches the server's current ETag, it returns 304 with no body instead of re-sending the whole resource. The PUT branch implements optimistic concurrency: it refuses the update with 412 if the client's If-Match doesn't match the current ETag, meaning the resource changed since the client last read it. This pattern — ETag plus If-None-Match/If-Match — is exactly how HTTP caching and safe concurrent editing are implemented without any server-side locking.
<?php
$userExists = false;
if (!$userExists) {
http_response_code(404);
echo json_encode(['error' => 'User not found']);
} else {
http_response_code(200);
echo json_encode(['id' => 1, 'name' => 'Ada']);
}<?php
// Creating a resource, with validation and conflict handling
$input = json_decode(file_get_contents('php://input'), true) ?? [];
header('Content-Type: application/json');
if (empty($input['username'])) {
http_response_code(422);
echo json_encode(['error' => 'username is required']);
exit;
}
$usernameTaken = in_array($input['username'], ['ada', 'grace'], true);
if ($usernameTaken) {
http_response_code(409);
echo json_encode(['error' => 'username already exists']);
exit;
}
$newId = 77;
http_response_code(201);
header("Location: /users/$newId");
echo json_encode(['id' => $newId, 'username' => $input['username']]);<?php
// Conditional GET with caching (304) and optimistic concurrency (412)
$currentEtag = '"v7"';
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
$clientEtag = $_SERVER['HTTP_IF_NONE_MATCH'] ?? null;
if ($clientEtag === $currentEtag) {
http_response_code(304); // cached copy is still valid
exit; // no body on 304
}
http_response_code(200);
header("ETag: $currentEtag");
header('Content-Type: application/json');
echo json_encode(['id' => 42, 'name' => 'Wireless Mouse']);
}
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'PUT') {
$clientEtag = $_SERVER['HTTP_IF_MATCH'] ?? null;
if ($clientEtag !== $currentEtag) {
http_response_code(412); // someone else changed it since you last fetched
header('Content-Type: application/json');
echo json_encode(['error' => 'Resource has changed, refetch and retry']);
exit;
}
http_response_code(200);
echo json_encode(['id' => 42, 'name' => 'Updated name']);
}Status code classes
The first digit alone tells a client (or a monitoring dashboard) the broad category before it even reads which specific code came back.
1xx Informational "hold on, more coming"
2xx Success "here you go" 200 201 202 204
3xx Redirection "look over there" 301 302 304 307 308
4xx Client Error "you made a mistake" 400 401 403 404 405 409 412 413 415 422 429
5xx Server Error "I made a mistake" 500 502 503 504Important Rules
Use the most specific correct code, not just 200/400/500 for everything — clients and tooling depend on the distinction.
201 Created should be paired with a Location header pointing at the new resource.
422 is for semantically invalid input (validation failures); 400 is for structurally malformed input the server can't even parse.
401 means 'not authenticated'; 403 means 'authenticated (or not) but not permitted' — don't swap them.
5xx codes should never be returned for client mistakes — if the client's input caused it, it's a 4xx.
Where this shows up
Load balancers and uptime monitors watch for 5xx rates to trigger alerts; CDNs and browsers use 304/301/308 to avoid re-fetching unchanged content; API clients (and libraries like Guzzle or fetch) branch their retry logic directly on whether a status is 429/503 (retryable) versus 400/422 (not retryable, fix the request first). Getting these right is often the difference between an API that's pleasant to integrate against and one that requires reading response bodies just to know what happened.
<?php // Returning 200 for everything, including failures http_response_code(200); echo json_encode(['error' => 'user not found']);
<?php http_response_code(404); echo json_encode(['error' => 'user not found']);
Always returning 200 forces every API client to parse the body just to know if something failed, defeats HTTP-level caching/retry/monitoring behavior, and breaks tooling (like fetch()'s response.ok) that relies on the status code being accurate.
A client fetches a resource with If-None-Match matching the server's current ETag. What should the server return? A. 200 with the full body B. 304 with no body C. 412 Precondition Failed D. 404 Not Found
B. 304 with no body — 304 Not Modified tells the client its cached copy is still valid, so the server deliberately sends no body, saving bandwidth. This is the core mechanism behind HTTP conditional caching.
What's the key practical difference between 301/302 and 307/308? A. 307/308 are deprecated and should never be used B. 301/302 only work over HTTPS C. 307/308 explicitly guarantee the original method and body are preserved on the redirected request, unlike the historically loose 301/302 D. There is no real difference; they are interchangeable in all cases
C. 307/308 explicitly guarantee the original method and body are preserved on the redirected request, unlike the historically loose 301/302 — 301 and 302 have a long-standing quirk where clients may re-send the redirected request as GET even if the original was POST. 307 and 308 close that gap by guaranteeing the method and body are preserved exactly.
A request body is valid JSON but is missing a required 'email' field. Which status code fits best? A. 400 Bad Request B. 404 Not Found C. 422 Unprocessable Entity D. 500 Internal Server Error
C. 422 Unprocessable Entity — 422 is for requests that are structurally well-formed (valid JSON here) but fail semantic/business validation, such as a missing required field. 400 is reserved for requests the server can't even parse.
Which status code means 'the client has sent too many requests and should slow down'? A. 409 Conflict B. 429 Too Many Requests C. 503 Service Unavailable D. 413 Payload Too Large
B. 429 Too Many Requests — 429 Too Many Requests signals rate limiting on the client's own request volume, conventionally paired with a Retry-After header. 503 is a general server-side unavailability, not specifically about the client's rate.
Write a PHP script that reads a JSON body from php://input. If the body can't be parsed as JSON, return 400. If it parses but is missing an 'email' field, return 422. If the email is '[email protected]', return 409. Otherwise return 201 with a Location header at /users/99.
<?php $raw = file_get_contents('php://input'); $data = json_decode($raw, true); header('Content-Type: application/json'); if (json_last_error() !== JSON_ERROR_NONE) { http_response_code(400); echo json_encode(['error' => 'invalid JSON']); exit; } if (empty($data['email'])) { http_response_code(422); echo json_encode(['error' => 'email is required']); exit; } if ($data['email'] === '[email protected]') { http_response_code(409); echo json_encode(['error' => 'email already registered']); exit; } http_response_code(201); header('Location: /users/99'); echo json_encode(['id' => 99, 'email' => $data['email']]);
json_decode() returns null both for invalid JSON and for a literal JSON null — check json_last_error() to distinguish invalid JSON from valid-but-empty input.