Level: advanced. You will build a small REST API for tasks. A mobile app or a JavaScript front end could use it. It uses everything from Working with JSON and APIs: methods, status codes, JSON bodies and token authentication.
The endpoints
| Request | Meaning | Success | Errors |
|---|---|---|---|
GET /tasks | List tasks (?done=1 filters) | 200 | 401 |
GET /tasks/{id} | One task | 200 | 404 |
POST /tasks | Create a task | 201 | 422 |
PATCH /tasks/{id} | Change title or done | 200 | 404, 422 |
DELETE /tasks/{id} | Delete | 204 | 404 |
Anything else: 404 for an unknown path, 405 for a wrong method, 401 without a valid token.
The design: request in, response out
The class does not echo anything. It receives a description of the request and returns a description of the response. A tiny front controller sends it. That keeps the logic testable without a web server.
<?php
declare(strict_types=1);
final class TasksApi
{
/** @param string[] $tokenHashes sha256 hashes of the valid API tokens */
public function __construct(private PDO $pdo, private array $tokenHashes) {}
/**
* @param array<string, string> $headers lower-case header names
* @param array<string, mixed> $query the parsed query string
* @return array{status: int, body: ?array}
*/
public function handle(string $method, string $path, array $headers = [], string $rawBody = "", array $query = []): array
{
if (!$this->authorised($headers["authorization"] ?? "")) {
return $this->error(401, "unauthorised", "A valid Bearer token is required.");
}
$path = "/" . trim($path, "/");
if ($path === "/tasks") {
return match ($method) {
"GET" => $this->list($query["done"] ?? null),
"POST" => $this->create($rawBody),
default => $this->error(405, "method_not_allowed", "Use GET or POST."),
};
}
if (preg_match('#^/tasks/(\d+)$#', $path, $m) === 1) {
$id = (int) $m[1];
return match ($method) {
"GET" => $this->show($id),
"PATCH" => $this->update($id, $rawBody),
"DELETE" => $this->remove($id),
default => $this->error(405, "method_not_allowed", "Use GET, PATCH or DELETE."),
};
}
return $this->error(404, "not_found", "No such endpoint.");
}
private function authorised(string $header): bool
{
if (preg_match('/^Bearer\s+(\S+)$/i', $header, $m) !== 1) {
return false;
}
$hash = hash("sha256", $m[1]);
foreach ($this->tokenHashes as $valid) {
if (hash_equals($valid, $hash)) {
return true;
}
}
return false;
}
private function list(?string $done): array
{
$sql = "SELECT id, title, done FROM tasks";
$params = [];
if ($done === "0" || $done === "1") {
$sql .= " WHERE done = :done";
$params["done"] = (int) $done;
}
$stmt = $this->pdo->prepare($sql . " ORDER BY id");
$stmt->execute($params);
return $this->ok(200, ["data" => array_map([$this, "present"], $stmt->fetchAll())]);
}
private function show(int $id): array
{
$task = $this->find($id);
return $task === null ? $this->error(404, "not_found", "Task $id does not exist.") : $this->ok(200, ["data" => $this->present($task)]);
}
private function create(string $rawBody): array
{
[$data, $error] = $this->decode($rawBody);
if ($error !== null) {
return $error;
}
$errors = $this->validate($data, requireTitle: true);
if ($errors !== []) {
return $this->error(422, "validation_failed", "The data is not valid.", $errors);
}
$this->pdo->prepare("INSERT INTO tasks (title, done, created_at) VALUES (:title, :done, :created)")
->execute(["title" => trim($data["title"]), "done" => (int) ($data["done"] ?? false), "created" => date("Y-m-d H:i:s")]);
return $this->ok(201, ["data" => $this->present($this->find((int) $this->pdo->lastInsertId()))]);
}
private function update(int $id, string $rawBody): array
{
$task = $this->find($id);
if ($task === null) {
return $this->error(404, "not_found", "Task $id does not exist.");
}
[$data, $error] = $this->decode($rawBody);
if ($error !== null) {
return $error;
}
$errors = $this->validate($data, requireTitle: false);
if ($errors !== []) {
return $this->error(422, "validation_failed", "The data is not valid.", $errors);
}
$title = array_key_exists("title", $data) ? trim($data["title"]) : $task["title"];
$done = array_key_exists("done", $data) ? (int) $data["done"] : (int) $task["done"];
$this->pdo->prepare("UPDATE tasks SET title = :title, done = :done WHERE id = :id")
->execute(["title" => $title, "done" => $done, "id" => $id]);
return $this->ok(200, ["data" => $this->present($this->find($id))]);
}
private function remove(int $id): array
{
$stmt = $this->pdo->prepare("DELETE FROM tasks WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->rowCount() === 1 ? ["status" => 204, "body" => null] : $this->error(404, "not_found", "Task $id does not exist.");
}
/** @return array{0: array, 1: ?array} decoded data and an error response */
private function decode(string $rawBody): array
{
try {
$data = json_decode($rawBody, true, 16, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [[], $this->error(400, "invalid_json", "The request body is not valid JSON.")];
}
return is_array($data) && !array_is_list($data)
? [$data, null]
: [[], $this->error(400, "invalid_json", "Send a JSON object.")];
}
private function validate(array $data, bool $requireTitle): array
{
$errors = [];
if (array_key_exists("title", $data)) {
if (!is_string($data["title"]) || trim($data["title"]) === "" || mb_strlen($data["title"]) > 200) {
$errors["title"] = "The title must be 1 to 200 characters.";
}
} elseif ($requireTitle) {
$errors["title"] = "The title is required.";
}
if (array_key_exists("done", $data) && !is_bool($data["done"])) {
$errors["done"] = "done must be true or false.";
}
return $errors;
}
private function find(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT id, title, done FROM tasks WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->fetch() ?: null;
}
private function present(array $row): array
{
return ["id" => (int) $row["id"], "title" => $row["title"], "done" => (bool) $row["done"]];
}
private function ok(int $status, array $body): array
{
return ["status" => $status, "body" => $body];
}
private function error(int $status, string $code, string $message, array $details = []): array
{
$error = ["code" => $code, "message" => $message];
if ($details !== []) {
$error["details"] = $details;
}
return ["status" => $status, "body" => ["error" => $error]];
}
}
Test the whole API with simulated requests
<?php
require __DIR__ . "/TasksApi.php";
$api = new TasksApi(require __DIR__ . "/db.php", [hash("sha256", "secret-token")]);
$auth = ["authorization" => "Bearer secret-token"];
function call(TasksApi $api, string $method, string $path, array $headers = [], string $body = "", array $query = []): void
{
$r = $api->handle($method, $path, $headers, $body, $query);
echo str_pad("$method $path", 20) . $r["status"] . " " . ($r["body"] === null ? "(no body)" : json_encode($r["body"], JSON_UNESCAPED_SLASHES)) . "\n";
}
call($api, "GET", "/tasks"); // no token
call($api, "GET", "/tasks", ["authorization" => "Bearer wrong"]); // wrong token
call($api, "POST", "/tasks", $auth, '{"title": "Write the API"}');
call($api, "POST", "/tasks", $auth, '{"title": "", "done": "yes"}');
call($api, "POST", "/tasks", $auth, "{not json");
call($api, "GET", "/tasks/1", $auth);
call($api, "PATCH", "/tasks/1", $auth, '{"done": true}');
call($api, "POST", "/tasks", $auth, '{"title": "Second task", "done": true}');
call($api, "GET", "/tasks", $auth, "", ["done" => "1"]);
call($api, "GET", "/tasks/99", $auth);
call($api, "PUT", "/tasks/1", $auth, "{}");
call($api, "DELETE", "/tasks/1", $auth);
call($api, "DELETE", "/tasks/1", $auth);
call($api, "GET", "/nothing", $auth);
GET /tasks 401 {"error":{"code":"unauthorised","message":"A valid Bearer token is required."}}
GET /tasks 401 {"error":{"code":"unauthorised","message":"A valid Bearer token is required."}}
POST /tasks 201 {"data":{"id":1,"title":"Write the API","done":false}}
POST /tasks 422 {"error":{"code":"validation_failed","message":"The data is not valid.","details":{"title":"The title must be 1 to 200 characters.","done":"done must be true or false."}}}
POST /tasks 400 {"error":{"code":"invalid_json","message":"The request body is not valid JSON."}}
GET /tasks/1 200 {"data":{"id":1,"title":"Write the API","done":false}}
PATCH /tasks/1 200 {"data":{"id":1,"title":"Write the API","done":true}}
POST /tasks 201 {"data":{"id":2,"title":"Second task","done":true}}
GET /tasks 200 {"data":[{"id":1,"title":"Write the API","done":true},{"id":2,"title":"Second task","done":true}]}
GET /tasks/99 404 {"error":{"code":"not_found","message":"Task 99 does not exist."}}
PUT /tasks/1 405 {"error":{"code":"method_not_allowed","message":"Use GET, PATCH or DELETE."}}
DELETE /tasks/1 204 (no body)
DELETE /tasks/1 404 {"error":{"code":"not_found","message":"Task 1 does not exist."}}
GET /nothing 404 {"error":{"code":"not_found","message":"No such endpoint."}}
Each line shows a rule at work: authentication first, then routing, then validation, and a consistent error shape. The query string (?done=1) is passed in as a separate array, so the class never touches $_GET.
The front controller
<?php
declare(strict_types=1);
require dirname(__DIR__) . "/TasksApi.php";
header("Content-Type: application/json; charset=utf-8");
header("X-Content-Type-Options: nosniff");
$tokens = array_filter(explode(",", (string) getenv("API_TOKEN_HASHES"))); // sha256 hashes, set in the environment
$api = new TasksApi(require dirname(__DIR__) . "/db.php", $tokens);
$headers = array_change_key_case(function_exists("getallheaders") ? getallheaders() : [], CASE_LOWER);
$body = file_get_contents("php://input", false, null, 0, 65536); // limit the body size
$response = $api->handle(
$_SERVER["REQUEST_METHOD"],
parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH) ?: "/",
$headers,
$body === false ? "" : $body,
$_GET
);
http_response_code($response["status"]);
if ($response["body"] !== null) {
echo json_encode($response["body"], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
Generate a token and its hash once:
php -r 'echo $t = bin2hex(random_bytes(24)), PHP_EOL, hash("sha256", $t), PHP_EOL;'
Give the first value to the client, and put the second (the hash) in API_TOKEN_HASHES.
Try it with curl
php -S localhost:8000 -t public
curl -i -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/tasks
curl -i -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Learn APIs"}' http://localhost:8000/tasks
What you practised
- Routing by method and path, with correct 405 and 404.
- Status codes:
200,201,204,400,401,404,405,422. - JSON decoding with errors, and validation of types, not only values.
- Bearer token authentication with hashed tokens and
hash_equals(). - Keeping the logic free of output so it can be tested.
Challenges
- Add pagination (
?page=2&per_page=10) and ametaobject. - Add per-user tasks and tokens stored in a database table.
- Add rate limiting and
429responses (Authentication Security). - Add CORS headers for one trusted front-end origin (Building a JSON Response).
- Write the API’s documentation as an OpenAPI file.