This capstone brings the entire Advanced course together: a REST API (Chapter 7) backed by a Repository (Chapter 2), authenticated with tokens, tested with PHPUnit and mocking (Chapter 4), with CSRF/XSS/SQL-injection defences (Chapter 5) and basic dependency management (Chapter 1) — the shape of a genuinely real, small production API.
Ch 1
composer.json + PSR-4
Ch 2
Repository pattern for tasks
Ch 3
Router + thin controllers
Ch 4
PHPUnit + mocked repository
Ch 5
Token auth, hash_equals
Ch 7
JSON responses, status codes
What We're Building
A minimal "Task API" — authenticated CRUD over a list of tasks, each belonging to a user, with a token required for every request.
task-api/
composer.json
src/
Router.php
Controllers/
TaskController.php
Models/
TaskRepository.php
MySqlTaskRepository.php
TaskNotFoundException.php
Auth.php
tests/
TaskControllerTest.php
public/index.php
The Repository Interface and Implementation
src/Models/TaskRepository.php
<?php
namespace App\Models;
interface TaskRepository {
public function all(): array;
public function find(int $id): array;
public function create(string $title): int;
public function delete(int $id): void;
}
?>
src/Models/MySqlTaskRepository.php
<?php
namespace App\Models;
class TaskNotFoundException extends \Exception {}
class MySqlTaskRepository implements TaskRepository {
public function __construct(private \PDO $pdo) {}
public function all(): array {
return $this->pdo->query("SELECT * FROM tasks")->fetchAll();
}
public function find(int $id): array {
$stmt = $this->pdo->prepare("SELECT * FROM tasks WHERE id = :id");
$stmt->execute(['id' => $id]);
$task = $stmt->fetch();
if (!$task) {
throw new TaskNotFoundException("No task with ID $id");
}
return $task;
}
public function create(string $title): int {
$stmt = $this->pdo->prepare("INSERT INTO tasks (title) VALUES (:title)");
$stmt->execute(['title' => $title]);
return (int)$this->pdo->lastInsertId();
}
public function delete(int $id): void {
$stmt = $this->pdo->prepare("DELETE FROM tasks WHERE id = :id");
$stmt->execute(['id' => $id]);
}
}
?>
Simple Token Authentication
src/Auth.php
<?php
namespace App;
class Auth {
private const VALID_TOKEN = 'a1b2c3d4e5f6';
public static function check(): bool {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = \str_replace('Bearer ', '', $header);
return \hash_equals(self::VALID_TOKEN, $token);
}
}
?>
A request is expected to include Authorization: Bearer a1b2c3d4e5f6. This is deliberately simplified for the capstone's scope — real APIs typically use per-user tokens (often JWTs) issued at login and stored hashed, never a single hardcoded shared secret.
The Controller — Tying Auth, Repository, and JSON Together
src/Controllers/TaskController.php
<?php
namespace App\Controllers;
use App\Auth;
use App\Models\TaskRepository;
use App\Models\TaskNotFoundException;
class TaskController {
public function __construct(private TaskRepository $tasks) {}
private function requireAuth() {
if (!Auth::check()) {
jsonResponse(['error' => 'Unauthorized'], 401);
}
}
public function index() {
$this->requireAuth();
jsonResponse($this->tasks->all());
}
public function show(string $id) {
$this->requireAuth();
try {
jsonResponse($this->tasks->find((int)$id));
} catch (TaskNotFoundException $e) {
jsonResponse(['error' => 'Task not found'], 404);
}
}
public function store() {
$this->requireAuth();
$input = \json_decode(\file_get_contents('php://input'), true);
if (empty($input['title'])) {
jsonResponse(['error' => 'title is required'], 422);
}
$id = $this->tasks->create($input['title']);
jsonResponse(['id' => $id], 201);
}
}
?>
Every endpoint calls requireAuth() first — Chapter 5's auth check, applied consistently. The constructor depends on the TaskRepository interface, not the concrete MySqlTaskRepository — exactly what makes the test below possible without touching a real database at all.
Testing the Controller — With a Mocked Repository
tests/TaskControllerTest.php
<?php
use PHPUnit\Framework\TestCase;
use App\Controllers\TaskController;
use App\Models\TaskRepository;
use App\Models\TaskNotFoundException;
class TaskControllerTest extends TestCase {
public function testShowReturns404ForMissingTask() {
$mockRepo = $this->createMock(TaskRepository::class);
$mockRepo->method('find')->willThrowException(new TaskNotFoundException());
$controller = new TaskController($mockRepo);
$this->expectOutputRegex('/Task not found/');
$controller->show('999');
}
}
?>
No real database, no real authentication header — this test verifies TaskController's own logic in complete isolation. This is the entire payoff of designing against an interface from the very start, demonstrated concretely rather than just described.
Deliberate simplifications for a capstone of this scope
The hardcoded single auth token, no rate limiting, and no pagination are all genuine gaps a production API would need to close — flagged here as known scope boundaries, not oversights, exactly matching the pattern from the Intermediate capstone.
Coding Challenge — Extend the Capstone
Final Challenge
Add a destroy(string $id) method to TaskController (auth-checked, returns 204 on success or 404 via TaskNotFoundException), and write a corresponding PHPUnit test using a mocked TaskRepository whose delete() method is asserted to have been called exactly once with the correct ID, using $mockRepo->expects($this->once())->method('delete')->with(5);.
📄 View solution
Course 3 Complete — PHP Advanced
- Chapters 1-9 covered: Composer in depth, design patterns, MVC from scratch, PHPUnit/mocking, security in depth, performance/caching, framework overview, REST APIs, async/queues
- This capstone combined Repository, mocking, token auth, and REST conventions into one small, genuinely testable API
- Known, deliberate gaps: single hardcoded token rather than per-user auth, no rate limiting, no pagination
- The PHP series (Fundamentals → Intermediate → Advanced) is now complete — 29 chapters across 3 courses, each with coding challenges and worked solutions