Say What You Mean
A REST API talks through two vocabularies. The method says what the client wants: GET read, POST create, PUT replace, PATCH change a part, DELETE remove. The status code says how it went: 2xx success, 4xx the client's mistake, 5xx the server's.
A detail worth knowing: PUT is idempotent — sending it twice ends the same as once. POST is not: two POSTs create two resources.
The Right Method
The router reads the verb from $_SERVER["REQUEST_METHOD"] and sends each one to its own branch. Creation answers 201 Created and points at the new resource with a Location header; deletion answers 204 No Content — success with nothing to say.
GET, POST and DELETE on one resource
Three verbs, three answers: the list comes back with 200, the creation with 201 and a Location, the deletion with 204 and an empty body.
Output: PHP script
<?php
// One branch per verb: the method is part of the route.
$router = <<<'PHP'
<?php
$method = $_SERVER["REQUEST_METHOD"];
$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$segments = explode("/", trim($path, "/"));
if ($method === "GET" && $path === "/notes") {
echo "notes: shopping, ideas\n";
} elseif ($method === "POST" && $path === "/notes") {
header("Location: /notes/3");
http_response_code(201);
echo "created\n";
} elseif ($method === "DELETE" && count($segments) === 2 && $segments[0] === "notes") {
http_response_code(204);
} else {
http_response_code(404);
}
PHP;
function httpRequest(string $url, string $method = "GET", array $headers = [], ?string $body = null): array {
$options = ["method" => $method, "ignore_errors" => true];
if ($headers !== []) {
$options["header"] = implode("\r\n", $headers);
}
if ($body !== null) {
$options["content"] = $body;
}
$responseBody = file_get_contents($url, false, stream_context_create(["http" => $options]));
return [(int) explode(" ", $http_response_header[0])[1], $responseBody, $http_response_header];
}
$routerFile = sys_get_temp_dir() . "/pc-methods.php";
file_put_contents($routerFile, $router);
// A free port, then PHP's built-in web server as a child process.
$probe = stream_socket_server("tcp://127.0.0.1:0");
$port = (int) explode(":", stream_socket_get_name($probe, false))[1];
fclose($probe);
$server = proc_open(["php", "-S", "127.0.0.1:$port", $routerFile],
[1 => ["file", "/dev/null", "w"], 2 => ["file", "/dev/null", "w"]], $pipes);
do {
usleep(20000);
$up = @fsockopen("127.0.0.1", $port);
} while ($up === false);
fclose($up);
$base = "http://127.0.0.1:$port";
[$status, $body] = httpRequest("$base/notes");
echo "GET /notes -> $status " . trim($body) . "\n";
[$status, , $headers] = httpRequest("$base/notes", "POST",
["Content-Type: application/json"], '{"text": "call Ben"}');
$location = "";
foreach ($headers as $headerLine) {
if (stripos($headerLine, "Location:") === 0) {
$location = trim(substr($headerLine, 9));
}
}
echo "POST /notes -> $status Location: $location\n";
[$status] = httpRequest("$base/notes/3", "DELETE");
echo "DELETE /notes/3 -> $status (no content)\n";
proc_terminate($server);
proc_close($server);
unlink($routerFile);
The Right Status Code
Errors deserve precision too: a missing resource is 404 Not Found (with a JSON error body so machines can read it), and a wrong verb on a known path is 405 Method Not Allowed — with an Allow header naming the verbs that would work. The router script answers both itself, picking the status with http_response_code().
200, 404 and 405
The existing note returns 200 with data, the missing one 404 with a JSON error, and the DELETE on a GET-only route is refused with 405 and an Allow: GET header.
Output: PHP script
<?php
// 200 for found, 404 for missing, 405 for the wrong verb.
$router = <<<'PHP'
<?php
$notes = ["1" => "shopping list"];
$method = $_SERVER["REQUEST_METHOD"];
$segments = explode("/", trim(parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH), "/"));
$id = $segments[1] ?? "";
if ($method !== "GET") {
// Wrong verb on a known path: refuse it, and say what would work.
header("Allow: GET");
http_response_code(405);
} elseif (!isset($notes[$id])) {
header("Content-Type: application/json");
http_response_code(404);
echo '{"error": "note not found"}' . "\n";
} else {
header("Content-Type: application/json");
echo json_encode(["id" => $id, "text" => $notes[$id]]) . "\n";
}
PHP;
function httpRequest(string $url, string $method = "GET", array $headers = [], ?string $body = null): array {
$options = ["method" => $method, "ignore_errors" => true];
if ($headers !== []) {
$options["header"] = implode("\r\n", $headers);
}
if ($body !== null) {
$options["content"] = $body;
}
$responseBody = file_get_contents($url, false, stream_context_create(["http" => $options]));
return [(int) explode(" ", $http_response_header[0])[1], $responseBody, $http_response_header];
}
$routerFile = sys_get_temp_dir() . "/pc-status-codes.php";
file_put_contents($routerFile, $router);
// A free port, then PHP's built-in web server as a child process.
$probe = stream_socket_server("tcp://127.0.0.1:0");
$port = (int) explode(":", stream_socket_get_name($probe, false))[1];
fclose($probe);
$server = proc_open(["php", "-S", "127.0.0.1:$port", $routerFile],
[1 => ["file", "/dev/null", "w"], 2 => ["file", "/dev/null", "w"]], $pipes);
do {
usleep(20000);
$up = @fsockopen("127.0.0.1", $port);
} while ($up === false);
fclose($up);
$base = "http://127.0.0.1:$port";
foreach (["/notes/1", "/notes/99"] as $path) {
[$status, $body] = httpRequest("$base$path");
printf("GET %s -> %d %s\n", $path, $status, trim($body));
}
[, , $headers] = httpRequest("$base/notes/1", "DELETE");
[, $statusText] = explode(" ", $headers[0], 2);
$allow = "";
foreach ($headers as $headerLine) {
if (stripos($headerLine, "Allow:") === 0) {
$allow = trim(substr($headerLine, 6));
}
}
echo "DELETE /notes/1 -> $statusText (Allow: $allow)\n";
proc_terminate($server);
proc_close($server);
unlink($routerFile);