Project Walkthrough: Building a REST API for a Tasks Resource
Design and build a small but complete REST API in plain PHP — a front-controller router, middleware pipeline, JSON request/response handling, request validation, token-based authentication, full CRUD for a tasks resource, pagination and filtering, and an OpenAPI description of one endpoint — by combining routing, OOP, HTTP, security, and database lessons already covered earlier in the course.
This is the first of the course's guided projects, and it exists to tie together everything from the earlier HTTP, OOP, database, and security lessons into one working thing you can actually run. Rather than introducing new syntax, this project asks a design question at every step: how do you turn a raw HTTP request into a routed, authenticated, validated call into your application logic, and turn the result back into a correct JSON HTTP response? We'll build a REST API for managing tasks (think a minimal to-do backend), starting from a single front controller and layering in a router, middleware, a controller, validation, and pagination, finishing with a short OpenAPI YAML excerpt documenting the collection endpoint. By the end you'll have a self-contained public/index.php entry point plus a handful of supporting classes that together behave like a scaled-down version of the routing layer inside frameworks like Laravel or Slim — except you'll understand every line of it, because you wrote it.
Project Overview and Requirements
Before writing a single line of routing code, it's worth pinning down exactly what the API needs to do, the same way you would before starting any real project. We're building a tasks resource with the standard REST verbs: GET /tasks lists tasks with pagination and optional filtering by status, GET /tasks/{id} fetches one task, POST /tasks creates a task, PATCH /tasks/{id} updates one, and DELETE /tasks/{id} removes one. Every request and response body is JSON, following the conventions from the earlier lesson on building JSON APIs with PHP — correct Content-Type headers, consistent envelope shapes, and HTTP status codes that actually mean what they say (201 Created, 404 Not Found, 422 Unprocessable Entity, and so on).
On top of the pure CRUD behavior, the API needs two cross-cutting concerns that apply to nearly every request: authentication and logging. Every request except perhaps a health-check endpoint must present a valid bearer token, and every request — successful or not — should be logged with enough detail to debug production issues later. Handling both of these inside each controller method would mean duplicating the same checks everywhere and inevitably forgetting one somewhere, which is exactly the kind of repeated, easy-to-miss logic that the middleware pattern exists to solve.
Finally, the API needs to be discoverable and documented the way a real API would be: something a frontend team or a third-party integrator could read without opening your source code. That's where OpenAPI comes in — a machine-readable, YAML-based specification format that describes the shape of your endpoints, their parameters, and their responses, which tools like Swagger UI can render into interactive documentation.
• Resource: tasks — full CRUD (list, get, create, update, delete). • Cross-cutting concerns: bearer-token auth and request logging, both implemented as middleware. • All bodies are JSON; status codes follow HTTP semantics (201, 404, 422, 401, etc.). • List endpoint supports pagination (page, per_page) and filtering (status). • Deliverable includes an OpenAPI YAML excerpt for the collection endpoint.
Architecture and File Structure
A REST API in plain PHP, without a framework, still benefits enormously from the same separation of concerns a framework would enforce: a single entry point that all requests flow through (the front controller pattern from the HTTP lesson), a router that decides which code handles a given method+path pair, middleware that wraps the request/response cycle, and controllers that contain the actual business logic. Keeping these responsibilities in separate classes means you can test the router without touching a database, and test the controller without spinning up a web server — a direct application of the testing and dependency-injection principles from earlier in the course.
The file layout for this project looks like: public/index.php (the single front controller every request hits, configured as the web server's document root), src/Router.php (maps method+path to a handler), src/Middleware/AuthMiddleware.php and src/Middleware/LoggingMiddleware.php, src/Http/Request.php and src/Http/Response.php (thin wrappers around superglobals and json_encode, echoing the request/response abstraction lesson), src/Controllers/TaskController.php, and src/Validation/TaskValidator.php. This mirrors, in miniature, the directory structure you'd find in Slim or a stripped-down Laravel app, and it's the structure worth internalizing because nearly every PHP web framework is a more elaborate version of exactly this.
The router itself is intentionally simple: it stores an array of route definitions (method, path pattern, handler) and, on each request, walks that array looking for a pattern match, extracting path parameters like {id} along the way with a regular expression built from the pattern. This is the same idea as the .htaccess/rewrite-rule based routing covered in the URL rewriting lesson, just implemented in PHP instead of in the web server's configuration, which gives you far more flexibility (middleware, typed parameters, testability) at the cost of writing a little more code up front.
• public/index.php — single front controller and entry point. • src/Router.php — method+path → handler dispatch, with {param} extraction. • src/Middleware/* — AuthMiddleware, LoggingMiddleware, composed as a pipeline. • src/Http/Request.php, src/Http/Response.php — request/response value objects. • src/Controllers/TaskController.php — CRUD actions. • src/Validation/TaskValidator.php — input validation rules for create/update.
Routing and the Middleware Pipeline
The router's job is narrow on purpose: given the incoming HTTP method and path, find the matching route and invoke its handler, passing along any path parameters it extracted. Each registered route also carries a list of middleware that should run before the handler — for this API, essentially every route runs through LoggingMiddleware first and AuthMiddleware second, so a request is logged even if authentication later fails, which is exactly the behavior you want for debugging and auditing.
Middleware in this project is modeled the same way it is in most PHP frameworks: each middleware is a class with a handle(Request $request, callable $next): Response method. It can inspect or modify the request, decide to short-circuit and return its own response (as AuthMiddleware does when the bearer token is missing or invalid, returning a 401 without ever reaching the controller), or call $next($request) to continue down the pipeline and then inspect or modify the response coming back. Building the pipeline is just composing these handle calls, innermost being the controller action wrapped as a final "handler", each middleware wrapping the next.
This pipeline pattern is worth dwelling on because it recurs constantly in web backends regardless of language: Express middleware in Node, Django/Rails middleware stacks, and ASP.NET Core's pipeline are all the same shape. Once you've built one by hand in PHP, recognizing and configuring it in any framework becomes far easier, because you already understand what's happening underneath the framework's convenience methods.
• Route table: array of {method, pattern, handler, middleware[]}. • Middleware signature: handle(Request $request, callable $next): Response. • LoggingMiddleware runs first (logs every request, even auth failures). • AuthMiddleware runs second (validates the bearer token, returns 401 on failure). • Composing middleware = nesting handle calls around the final controller action.
Core Implementation: The Task Controller
With routing and middleware in place, the controller's job is comfortably narrow: read validated input, talk to the database via the repository/PDO patterns from the database lessons, and return a Response object carrying the right status code and JSON body. Each CRUD action maps to one HTTP verb+path combination — index() for GET /tasks, show($id) for GET /tasks/{id}, store() for POST /tasks, update($id) for PATCH /tasks/{id}, and destroy($id) for DELETE /tasks/{id} — the same resourceful-controller convention used by Rails, Laravel, and most REST frameworks, because it maps so cleanly onto the CRUD operations a resource naturally supports.
Every write action (store, update) runs the incoming JSON body through TaskValidator before touching the database, following the request-validation lesson's pattern of validating early and returning a structured 422 with per-field error messages rather than letting a malformed value crash deeper in the stack or, worse, land silently in the database. Reads are kept simple and defensive: show() looks up the task by ID and returns a 404 immediately if it doesn't exist, rather than letting a null value propagate and cause a confusing 500 error later.
Prepared statements are used for every database interaction, exactly as covered in the SQL injection prevention lesson — user-supplied values like $id, $status, and the create/update payload fields are always bound as parameters, never concatenated into SQL strings. This matters even more in an API than in a traditional web app, because every field of every request is attacker-controlled input by definition.
• Resourceful actions: index, show, store, update, destroy — one per verb+path. • Validation happens before any database write, using TaskValidator. • show()/update()/destroy() return 404 immediately for a missing ID. • All queries use prepared statements with bound parameters — no string concatenation.
Validation, Auth, and Error Handling
TaskValidator encodes the create/update rules explicitly: title is required, must be a string, and must be 1–200 characters; status is optional but, if present, must be one of a fixed set (pending, in_progress, done); due_date, if present, must parse as a valid ISO-8601 date. Rather than throwing exceptions for each individual failure, the validator collects every violation into a list and returns them together, so a client fixing their request sees all the problems in one round trip instead of one at a time — a small usability detail that makes an API meaningfully more pleasant to integrate against.
AuthMiddleware implements a deliberately simple token check appropriate for a learning project: it reads the Authorization: Bearer <token> header, and validates the token — in the simplest version by comparing it (using a constant-time comparison, hash_equals(), to avoid timing attacks, as covered in the security lessons) against a value loaded from an environment variable, or, for a more realistic setup, by verifying it as a signed JWT the way the JWT authentication lesson describes, checking both the signature and the expiry claim. Either way, a missing or invalid token short-circuits the pipeline with a 401 Unauthorized JSON body before the request ever reaches the controller or the database.
Uncaught errors anywhere in the pipeline are caught by a single top-level try/catch in public/index.php, following the centralized error-handling pattern from the exceptions lesson: validation failures become 422s with field errors, a missing resource becomes a 404, an authentication failure becomes a 401, and anything unexpected becomes a generic 500 with a logged stack trace but a deliberately vague message sent to the client — you never want to leak internal exception details, file paths, or SQL fragments to an API consumer.
• Validator collects all field errors at once instead of failing on the first one. • Auth: constant-time bearer-token comparison, or full JWT signature + expiry check. • Central try/catch maps exception types to status codes (422 / 401 / 404 / 500). • 500 responses never leak stack traces or internal details to the client — only to the log.
Pagination, Filtering, and the OpenAPI Contract
The list endpoint, GET /tasks, accepts page and per_page query parameters (with sane defaults and an upper bound on per_page to prevent someone requesting ten thousand rows at once) and translates them into a LIMIT/OFFSET pair in the underlying query, plus a COUNT(*) query to compute total pages for the response's pagination metadata. It also accepts an optional status query parameter that, when present, adds a WHERE status = :status clause — filtering and pagination compose naturally because both are just additional, optional pieces of the same query being built up conditionally, the same query-building technique from the dynamic SQL lesson.
The JSON response envelope wraps the array of tasks with a meta object describing the current page, per-page count, total items, and total pages — a convention borrowed from JSON:API and widely used in real APIs, because it lets a client build "next/previous page" UI without a separate request. This is a good moment to reinforce a rule from the API design lessons: pagination metadata belongs in the envelope, not in custom HTTP headers, unless you have a specific reason (like Link headers for HATEOAS-style APIs) to do otherwise.
Finally, documenting the endpoint in OpenAPI turns the informal contract we've been building into something tools can consume: Swagger UI can render it as interactive docs, and code generators can produce typed API clients from it automatically. The YAML excerpt below describes GET /tasks, including its query parameters and the shape of a paginated response — in a real project this would live in an openapi.yaml file at the project root and grow to cover every endpoint.
• page and per_page map to LIMIT/OFFSET; per_page is capped to prevent abuse. • status query parameter adds a conditional WHERE clause. • Response envelope: { data: [...], meta: { page, per_page, total, total_pages } }. • OpenAPI YAML documents the endpoint's parameters and response schema for tooling and consumers.
Testing and Extending the API
Because the router, middleware, and controller are all plain classes with constructor-injected dependencies (a PDO connection, in particular), each layer can be tested in isolation with PHPUnit the way the testing lessons describe: a unit test for TaskValidator checks each validation rule with valid and invalid payloads; a unit test for Router checks that a path like /tasks/42 correctly extracts id => 42; and a feature test spins up the whole pipeline against an in-memory SQLite database (or a transactional test database, per the database-testing lesson) and asserts on the full JSON response for a real HTTP-shaped request.
From here the project extends naturally in directions a real API would need: rate limiting (tracking request counts per token, covered conceptually in the earlier rate-limiting lesson), API versioning (prefixing routes with /v1/), richer JWT-based auth with refresh tokens, and content negotiation if the API needed to support more than JSON. None of these require rearchitecting what's here — they slot into the middleware pipeline or the route table exactly the way AuthMiddleware and LoggingMiddleware already do, which is the payoff of having built a clean pipeline in the first place instead of scattering if checks through every controller method.
• Unit-test the validator and router in isolation; feature-test the full pipeline end to end. • Use SQLite in-memory or a transactional test DB so tests don't touch production data. • Natural extensions: rate limiting, /v1/ versioning, refresh-token JWT auth.
Code Examples
Each method maps one route to one focused responsibility: parse input, validate or check existence, run a parameterized query, and return a Response with an intentional status code. Filtering and pagination in index() build the WHERE clause and LIMIT/OFFSET conditionally instead of duplicating the query, and store()/update() reuse show() to return the freshly persisted representation, avoiding drift between what was written and what's echoed back.
hash_equals() performs a constant-time comparison so an attacker can't infer the correct token byte-by-byte from response timing. buildPipeline() wraps the controller with each middleware in reverse order, so the first middleware in the array is the outermost layer — logging wraps auth wraps the controller, meaning logging always runs, but auth can still short-circuit before the controller executes.
This excerpt fully documents the collection endpoint: its auth requirement, its three query parameters with their constraints, and the exact shape of a 200 response via a reusable Task schema — enough for Swagger UI to render interactive docs or for a client-generator tool to produce a typed HTTP client automatically.
// Building SQL by concatenating the status filter directly $sql = "SELECT * FROM tasks WHERE status = '" . $request->query('status') . "'";
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE status = :status'); $stmt->execute(['status' => $request->query('status')]);
Concatenating a query parameter straight into SQL reopens the exact SQL injection hole the earlier security lessons warned about — an attacker sending status=x' OR '1'='1 would dump every task. Every value that originates from the request must go through a bound parameter, with no exceptions for 'just a filter'.
if ($header !== 'Bearer '. $expectedToken) { return unauthorized(); } if (!hash_equals($expectedToken, $token)) { return unauthorized(); }
PHP's !== comparison short-circuits on the first differing byte, so the time it takes to fail depends on how many leading characters of the token were correct — a timing side-channel an attacker can exploit to guess the token byte by byte. hash_equals() always takes the same time regardless of where the strings diverge.
// Letting per_page come straight from the client with no cap $perPage = (int) $request->query('per_page'); // ... LIMIT :perPage
$perPage = min(50, max(1, (int) ($request->query('per_page') ?? 10)));
An unbounded per_page lets a client request an enormous page size in one call, forcing the database to materialize and serialize far more rows than the API was designed to return — a simple, accidental denial-of-service vector. Always clamp pagination inputs to a sane, documented range.
In the middleware pipeline built by buildPipeline(), why is LoggingMiddleware placed before AuthMiddleware in the route's middleware list? A. So logging only runs for authenticated requests B. So every request is logged, even ones that fail authentication C. Because PHP requires logging middleware to run first D. It has no effect on ordering; middleware order is arbitrary
B. So every request is logged, even ones that fail authentication — Because the pipeline is built outside-in, the first middleware in the list becomes the outermost wrapper. Placing LoggingMiddleware first means it wraps everything, including AuthMiddleware, so a request is logged whether or not it passes authentication.
Why does AuthMiddleware use hash_equals() instead of the == or === operator to compare tokens? A. hash_equals() is faster than == for short strings B. == and === don't work on strings in PHP C. hash_equals() runs in constant time, preventing a timing attack that could reveal the token byte by byte D. hash_equals() automatically hashes both values with SHA-256
C. hash_equals() runs in constant time, preventing a timing attack that could reveal the token byte by byte — String comparison operators typically stop at the first mismatched byte, making the comparison time leak information about how much of the guess was correct. hash_equals() is designed to take the same amount of time regardless of where (or whether) the strings differ.
What is the purpose of the meta object in the paginated list response? A. It stores debug information only relevant during development B. It carries pagination context (page, per_page, total, total_pages) so a client can build next/previous UI without another request C. It replaces the need for HTTP status codes D. It is required by the JSON specification for every array response
B. It carries pagination context (page, per_page, total, total_pages) so a client can build next/previous UI without another request — Wrapping the data array with a meta object is a common REST convention that gives clients everything they need to paginate without a separate count query or guesswork.
Why does TaskValidator collect all validation errors into a list instead of returning immediately on the first failure? A. PHP doesn't support early returns from validation functions B. It's required by the OpenAPI specification C. So a client can see and fix every problem with their request in one round trip instead of one error at a time D. It has no practical benefit; it's purely stylistic
C. So a client can see and fix every problem with their request in one round trip instead of one error at a time — Returning every violation at once is meaningfully more usable for API consumers, who would otherwise have to resubmit repeatedly, fixing one field per attempt.
<?php
declare(strict_types=1);
final class TaskController
{
public function __construct(private PDO $db) {}
public function index(Request $request): Response
{
$page = max(1, (int) ($request->query('page') ?? 1));
$perPage = min(50, max(1, (int) ($request->query('per_page') ?? 10)));
$status = $request->query('status');
$where = [];
$params = [];
if ($status !== null) {
$where[] = 'status = :status';
$params['status'] = $status;
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$countStmt = $this->db->prepare("SELECT COUNT(*) FROM tasks {$whereSql}");
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$offset = ($page - 1) * $perPage;
$stmt = $this->db->prepare(
"SELECT id, title, status, due_date, created_at
FROM tasks {$whereSql}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset"
);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
}
$stmt->bindValue(':limit', $perPage, PDO::P
…<?php
declare(strict_types=1);
final class AuthMiddleware
{
public function __construct(private string $expectedToken) {}
public function handle(Request $request, callable $next): Response
{
$header = $request->header('Authorization') ?? '';
if (!str_starts_with($header, 'Bearer ')) {
return Response::json(['error' => 'Missing bearer token'], 401);
}
$token = substr($header, 7);
if (!hash_equals($this->expectedToken, $token)) {
return Response::json(['error' => 'Invalid token'], 401);
}
return $next($request);
}
}
final class LoggingMiddleware
{
public function __construct(private LoggerInterface $logger) {}
public function handle(Request $request, callable $next): Response
{
$start = microtime(true);
$response = $next($request);
$durationMs = (microtime(true) - $start) * 1000;
$this->logger->info(sprintf(
'%s %s -> %d (%.1fms)',
$request->method(),
$request->path(),
$response->statusCode(),
$durationMs
));
return $response;
}
}
// Composing the pipeline:
…openapi: 3.0.3
info:
title: SampleApp Tasks API
version: 1.0.0
paths:
/tasks:
get:
summary: List tasks
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: per_page
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
- name: status
in: query
schema: { type: string, enum: [pending, in_progress, done] }
responses:
'200':
description: A paginated list of tasks
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Task'
meta:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total: { type: integer }
total_pages: { type: integer }
components:
securitySchemes:
bearerAuth:
ty
…