Skip to content

PHP Tutorial Practical PHP Projects

Project 10: A JSON API

Lesson 356 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

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

RequestMeaningSuccessErrors
GET /tasksList tasks (?done=1 filters)200401
GET /tasks/{id}One task200404
POST /tasksCreate a task201422
PATCH /tasks/{id}Change title or done200404, 422
DELETE /tasks/{id}Delete204404

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.

TasksApi.php
PHP
<?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

index.php
PHP
<?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);
Output
OUTPUT
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

public/index.php
PHP
<?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:

terminal
BASH
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

terminal
BASH
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

  1. Add pagination (?page=2&per_page=10) and a meta object.
  2. Add per-user tasks and tokens stored in a database table.
  3. Add rate limiting and 429 responses (Authentication Security).
  4. Add CORS headers for one trusted front-end origin (Building a JSON Response).
  5. Write the API’s documentation as an OpenAPI file.