PHPBuilding REST APIs

Building REST APIs

A REST API is, at its core, just a set of URLs that respond to HTTP requests with structured data — usually JSON — instead of an HTML page meant for a browser to render. Every web framework eventually wraps this up into routing tables and controller classes, but the underlying mechanics are plain PHP: reading which HTTP method and path the client requested, deciding what to do based on those two things, and sending back a response with the right status code and the right content type. Building a tiny REST endpoint by hand is a good way to actually see that mechanism before a framework hides it behind conveniences.

Reading the request method and path

Every incoming HTTP request PHP handles populates the $_SERVER superglobal with information about that request. Two entries matter most for routing: $_SERVER['REQUEST_METHOD'], a string like GET, POST, PUT, or DELETE, and $_SERVER['REQUEST_URI'], the path (and query string) the client requested. A hand-rolled router is just an if/switch chain — or a small routing table — that inspects both of those values and calls the right piece of code.

api/products.php — a tiny hand-rolled endpoint

PHP
<?php
declare(strict_types=1);

header('Content-Type: application/json');

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// Pretend this is a database lookup.
$products = [
    1 => ['id' => 1, 'name' => 'Keyboard', 'price' => 49.99],
    2 => ['id' => 2, 'name' => 'Mouse', 'price' => 19.99],
];

if ($method === 'GET' && $path === '/api/products') {
    http_response_code(200);
    echo json_encode(array_values($products));
    exit;
}

if ($method === 'GET' && preg_match('#^/api/products/(\d+)$#', $path, $m)) {
    $id = (int) $m[1];

    if (!isset($products[$id])) {
        http_response_code(404);
        echo json_encode(['error' => 'Product not found']);
        exit;
    }

    http_response_code(200);
    echo json_encode($products[$id]);
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'Route not found']);
$ curl -s http://localhost:8000/api/products/1
{"id":1,"name":"Keyboard","price":49.99}

$ curl -s -i http://localhost:8000/api/products/99
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"Product not found"}
JSON responses need the right content type

header('Content-Type: application/json') is easy to forget, but it matters more than it looks like it should: many HTTP clients and frontend frameworks decide how to parse a response body based on this header, and without it a perfectly valid JSON string can get treated as plain text. json_encode() turns a PHP array or object into that JSON string; the matching function, json_decode(), parses JSON back into a PHP value on the way in — for example, when reading a JSON request body sent with POST or PUT.

Reading a JSON request body

PHP
<?php
$method = $_SERVER['REQUEST_METHOD'];

if ($method === 'POST') {
    $rawBody = file_get_contents('php://input');
    $data = json_decode($rawBody, true);

    if (!is_array($data) || empty($data['name'])) {
        http_response_code(422);
        echo json_encode(['error' => 'A "name" field is required']);
        exit;
    }

    // ...create the product using $data...
    http_response_code(201);
    echo json_encode(['id' => 3, 'name' => $data['name']]);
}
Status codes carry meaning the body can't

http_response_code() sets the HTTP status line PHP sends back — 200 OK by default, but that default is frequently the wrong answer for anything other than a successful GET. A client consuming an API relies on the status code to decide how to react without even parsing the body: 200 for a normal success, 201 after successfully creating something, 404 when the requested resource doesn't exist, 422 when the request was understood but failed validation, and 500 for an unexpected server-side failure. Returning 200 for every response, with an {"error": "..."} body as the only signal something went wrong, forces every consumer to inspect the body just to know if the request even succeeded — which defeats a large part of the point of using HTTP status codes at all.

Always call exit after writing a response in a hand-rolled router
Without an early `exit` (or `return`, inside a function), execution keeps falling through to the next `if` block once a route has already sent its response — potentially reaching the closing catch-all 404 block and appending a second, contradictory JSON payload after the first one. Frameworks avoid this entirely because their routers return a single response object from a single matched route instead of writing output as a side effect inline, but a hand-rolled script has to enforce that discipline manually.
Where frameworks take over

The example above works, but it doesn't scale past a handful of routes: there's no automatic request validation, no consistent error-response shape, no middleware layer for concerns like authentication or rate limiting, and the routing logic itself grows into an unreadable chain of if statements as more endpoints get added. This is exactly the gap frameworks like Laravel, Symfony, and the lighter-weight Slim fill: a real routing layer that maps HTTP method and path patterns to controller methods, built-in request validation, consistent JSON error responses, and middleware for cross-cutting concerns, so a real API's route definitions stay readable even with dozens or hundreds of endpoints.

  • $_SERVER["REQUEST_METHOD"] and $_SERVER["REQUEST_URI"] are the two pieces of information a hand-rolled router inspects to decide what to do.

  • header("Content-Type: application/json") combined with json_encode() produces a proper JSON API response.

  • json_decode(file_get_contents("php://input"), true) reads a JSON request body sent with POST or PUT.

  • http_response_code() should reflect what actually happened — 200/201 for success, 404 for missing resources, 422 for validation failures, 500 for server errors.

  • A hand-rolled router must exit after each matched route to avoid falling through to later routes and sending a second response.

  • Frameworks like Laravel, Symfony, and Slim replace hand-rolled routing with dedicated routing, validation, and middleware layers once an API grows beyond a few endpoints.

Tip
Even in a tiny hand-rolled API, decide on one consistent JSON error shape early — something like `{"error": "message"}` — and use it for every failure response. Consumers of the API can then handle errors generically instead of special-casing the response format per endpoint, which is a habit worth keeping even after moving to a real framework.