Building a REST API in PHP

Course 3 · Ch 7
Building a REST API in PHP: Routing, JSON Responses, Status Codes
Adapting Chapter 3's mini-framework to serve data instead of HTML pages

A REST API serves the same kind of application logic as a normal web page — just returning JSON (Intermediate Chapter 7) instead of HTML, with HTTP methods and status codes carrying meaning that a typical web form-based app barely uses. This chapter adapts Chapter 3's Router/Controller structure directly into an API.

REST's Core Idea — HTTP Methods Map to CRUD

GET /posts
Read — list all posts
GET /posts/{id}
Read — one specific post
POST /posts
Create — a new post
PUT /posts/{id}
Update — replace a post
DELETE /posts/{id}
Delete — remove a post

This is exactly Intermediate Chapter 4's CRUD, expressed through HTTP methods rather than separate page names like new_post.php or delete_post.php.

Returning JSON Instead of HTML

<?php function jsonResponse($data, int $statusCode = 200) { http_response_code($statusCode); header('Content-Type: application/json'); echo json_encode($data); exit; } jsonResponse(['id' => 1, 'title' => 'Hello']); ?>

A REST API needs the Content-Type: application/json header (matching Intermediate Chapter 7's requirement when sending JSON via cURL) so client applications know to parse the response as JSON, not HTML. A small helper like this avoids repeating those three lines in every controller method.

HTTP Status Codes — Communicating What Happened

CodeMeaning
200 OKSuccess — GET, PUT succeeded
201 CreatedSuccess — POST created a new resource
204 No ContentSuccess — DELETE succeeded, nothing to return
400 Bad RequestThe request itself is malformed/invalid
401 UnauthorizedAuthentication required, and missing/invalid
403 ForbiddenAuthenticated, but not allowed to do this
404 Not FoundThe requested resource doesn't exist
422 Unprocessable EntityValid request format, but failed validation
500 Internal Server ErrorSomething broke on the server's end
Returning 200 for every response, even errors, is a common and genuinely unhelpful API mistake
A client application relies on status codes to decide how to react — retry, show a specific error, redirect to login. An API that always returns 200 regardless of what actually happened forces every client to additionally inspect the response body just to find out if something went wrong, defeating much of the purpose of having status codes at all.

An API Controller, Built on Chapter 3's Pattern

<?php namespace App\Controllers; use App\Models\Post; use App\Models\PostNotFoundException; class PostApiController { public function index() { $post = new Post(getDbConnection()); jsonResponse($post->all()); } public function show(string $id) { $post = new Post(getDbConnection()); try { jsonResponse($post->find((int)$id)); } catch (PostNotFoundException $e) { jsonResponse(['error' => 'Post not found'], 404); } } public function store() { $input = json_decode(file_get_contents('php://input'), true); if (empty($input['title']) || empty($input['body'])) { jsonResponse(['error' => 'title and body are required'], 422); } $post = new Post(getDbConnection()); $newId = $post->create($input['title'], $input['body']); jsonResponse(['id' => $newId], 201); } } ?>

file_get_contents('php://input') reads the raw request body — a JSON API client sends a JSON-encoded body rather than a normal HTML form, so $_POST (used throughout this entire course up to now) doesn't get populated; the body has to be read and decoded manually instead.

PostNotFoundException, written back in Intermediate Chapter 3, slots straight into this API controller
The exception itself never needed to be written knowing it would eventually power an API response — catching it here and converting it into a 404 JSON response is a perfect illustration of well-designed exceptions being reusable across very different contexts (a web page showing "not found" text, vs. an API returning a 404 status).

Routing API Requests by Method, Not Just Path

<?php $method = $_SERVER['REQUEST_METHOD']; $uri = strtok($_SERVER['REQUEST_URI'], '?'); $controller = new PostApiController(); match (true) { $method === 'GET' && $uri === '/posts' => $controller->index(), $method === 'POST' && $uri === '/posts' => $controller->store(), default => jsonResponse(['error' => 'Not found'], 404), }; ?>

Unlike Chapter 3's Router (which only mapped by path), an API router must check the HTTP method too — GET /posts and POST /posts share the same path but mean entirely different operations.

Coding Challenges

Challenge 1

Write a jsonResponse() helper function (as shown in the chapter), then write a small script simulating a "user not found" scenario: it should call jsonResponse(['error' => 'User not found'], 404). Explain in a comment why setting the status code AND the Content-Type header both matter, even though the JSON body itself is valid either way.

📄 View solution
Challenge 2

Write a destroy(string $id) method for PostApiController that deletes a post by ID and returns a 204 status with an empty body on success, or a 404 JSON error if the post doesn't exist (using the Post class's find() method first to check, catching PostNotFoundException).

📄 View solution
Challenge 3

Write a store() method (like the chapter's example) that additionally validates the title is no longer than 200 characters, returning 422 with a specific error message if it is. Then write the matching cURL-based test code (Intermediate Chapter 7) that POSTs a JSON body to this endpoint and checks the response status code and decoded body.

📄 View solution

Chapter 7 Quick Reference

  • HTTP methods map to CRUD: GET (read), POST (create), PUT (update), DELETE (delete)
  • jsonResponse() — sets status code + Content-Type, json_encode()'s the body, exits
  • 200/201/204 — success variants; 400/401/403/404/422 — client error variants; 500 — server error
  • file_get_contents('php://input') — reads the raw JSON request body; $_POST is NOT populated for JSON requests
  • Route by method AND path — GET /posts and POST /posts are different operations on the same path
  • Existing exceptions/models from earlier chapters slot directly into an API — no rewriting needed, just a different response format
  • Next chapter: asynchronous/concurrent patterns in PHP — queues, background jobs