Serving a JSON API
Read the request body from php://input, set the content type, return the right status per outcome, and keep one error shape.
$body = file_get_contents('php://input');An API endpoint is a PHP script that answers with data rather than a page. The request arrives the same way a form submission does. What changes is the body you send back and the Content-Type header that describes it.
Reading the body
A browser form sends application/x-www-form-urlencoded, and PHP parses that into $_POST for you. A JSON client sends application/json, which PHP does not parse at all. $_POST stays empty. You read the raw bytes yourself from the php://input stream with file_get_contents('php://input'), then decode them.
Answering
Send header('Content-Type: application/json; charset=utf-8') and set the status with http_response_code(). Both must come before any output. Give each outcome its own status: 200 for a successful read, 201 with a Location header for something created, 204 with no body for a deletion, 400 when the body is not valid JSON, 404 when the resource does not exist, 405 with an Allow header for the wrong method, 422 when the JSON parsed but the values are wrong, and 500 when the fault is yours.
One error shape
Decide on a single structure for every failure, such as an error object holding a machine-readable code and a human message. A consumer then writes one branch for errors instead of one per endpoint. Never put an exception message or a file path in it; log those instead.
Example
When to use it
- Let a JavaScript front end save a form without reloading the page.
- Give a mobile app read access to the same data the website shows.
- Accept a webhook from a payment provider and answer 200 so it stops retrying.
- Expose a report to an internal dashboard without opening the database to it.
More examples
public/api.php, the real endpoint
This is the endpoint as you would deploy it, with the two calls the sandbox cannot make: the database write and the headers. Note what goes where when the insert fails. The driver's message goes to error_log for you, and the client gets a fixed sentence, because a PDOException message can contain the query, the column names and sometimes the connection details.
<?php
declare(strict_types=1);
$pdo = require __DIR__ . '/../src/bootstrap.php'; // returns a configured PDO
header('Content-Type: application/json; charset=utf-8');
/** @param array<string, mixed> $payload */
function send(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
exit;
}
function fail(int $status, string $code, string $message): never
{
send($status, ['error' => ['code' => $code, 'message' => $message]]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
fail(405, 'method_not_allowed', 'Use POST.');
}
try {
$input = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
fail(400, 'invalid_json', 'The request body is not valid JSON.');
}
$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
fail(422, 'validation_failed', 'A valid email is required.');
}
try {
// A prepared statement. The value never becomes part of the SQL text.
$stmt = $pdo->prepare('INSERT INTO subscribers (email) VALUES (:email)');
$stmt->execute(['email' => $email]);
} catch (PDOException $e) {
error_log('subscribe failed: ' . $e->getMessage());
fail(500, 'server_error', 'The request could not be completed.');
}
$id = (int) $pdo->lastInsertId();
header('Location: /v1/subscribers/' . $id);
send(201, ['data' => ['id' => $id, 'email' => $email]]);
Rejecting the wrong method properly
A 405 without an Allow header tells the consumer they are wrong but not what would be right, and the HTTP specification requires the header. The OPTIONS branch costs three lines and saves a support conversation. Reach for this in the front controller, once, rather than in each handler.
<?php
declare(strict_types=1);
$allowed = ['GET', 'HEAD', 'POST'];
$method = $_SERVER['REQUEST_METHOD'];
// A client may ask what this URL accepts before sending anything.
if ($method === 'OPTIONS') {
header('Allow: ' . implode(', ', $allowed));
http_response_code(204);
exit;
}
if (!in_array($method, $allowed, true)) {
header('Content-Type: application/json; charset=utf-8');
header('Allow: ' . implode(', ', $allowed)); // required on a 405
http_response_code(405);
echo json_encode([
'error' => [
'code' => 'method_not_allowed',
'message' => 'This resource accepts ' . implode(', ', $allowed) . '.',
],
], JSON_THROW_ON_ERROR);
exit;
}
Returning a list read from the database
Three habits in one short file. The limit is clamped server-side so nobody can ask for a million rows. The integer bind avoids the LIMIT '20' syntax error that appears only on connections with emulated prepares. And the response is wrapped in an object, which means adding a meta field later is not a breaking change for anyone already reading data.
<?php
declare(strict_types=1);
// $pdo was built in bootstrap.php with ERRMODE_EXCEPTION and
// FETCH_ASSOC already set, so a fetch gives plain arrays.
$limit = min(100, max(1, (int) ($_GET['limit'] ?? 20)));
// LIMIT takes a placeholder, but with emulated prepares it would be quoted as
// a string, so bind it explicitly as an integer.
$stmt = $pdo->prepare('SELECT id, title FROM articles ORDER BY id LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
header('Content-Type: application/json; charset=utf-8');
http_response_code(200);
// A top-level object, not a bare array, so fields can be added later
// without changing the type of the response.
echo json_encode([
'data' => $rows,
'meta' => ['count' => count($rows), 'limit' => $limit],
], JSON_THROW_ON_ERROR);
Discussion