Introduction to "Build a Robust RESTful API with PHP 8, from Scratch!"
Hey there! I’m thrilled to welcome you to this exciting journey of building a powerful, scalable RESTful API from the ground up using PHP 8 and the N-Tier architecture. Whether you're a developer looking to level up your backend skills or someone curious about crafting clean, professional APIs, this course is designed with you in mind.
When I first started diving into API development, I remember feeling overwhelmed by the sheer number of concepts—REST principles, architecture patterns, security concerns, and more. But over time, I realized that breaking it down into clear, manageable steps makes all the difference. That’s exactly what this course does. Over 20 modules, we’ll walk together through every stage of creating a robust API, from setting up your environment to deploying a production-ready application.
In this course, you’ll learn how to leverage the modern features of PHP 8 to build an API that’s not only functional but also scalable, secure, and maintainable. We’ll use the N-Tier architecture to keep our code organized and flexible, ensuring it can grow with your project’s needs. By the end, you’ll have a fully functional API—think task management or e-commerce—and the confidence to tackle real-world backend challenges.
What makes this course special is its hands-on, practical approach. We’re not just talking theory here; we’re building a real API, step by step, with plenty of examples, best practices, and a touch of my own lessons learned along the way. Whether you’re coding for a startup, a side project, or just to sharpen your skills, I’m here to guide you through the process with clarity and a bit of fun.
So, grab your favorite code editor, maybe a cup of coffee, and let’s dive into the world of RESTful APIs with PHP 8. By the end of this course, you’ll have a project you’re proud of and the know-how to build APIs like a pro. Ready? Let’s get started!
Module 1: Introduction to RESTful APIs and PHP 8
Overview
Welcome to the first module of Build a Robust RESTful API with PHP 8, from Scratch! This module sets the foundation for our journey by introducing the core concepts of RESTful APIs, exploring the power of PHP 8, and understanding the N-Tier architecture. We’ll also outline the course objectives and the exciting project we’ll build together. By the end, you’ll have a clear understanding of what we’re aiming for and why these technologies matter.
Learning Objectives
- Understand the principles of RESTful APIs and their role in modern web development.
- Explore the features of PHP 8 that make it ideal for API development.
- Learn the basics of N-Tier architecture and its benefits for building scalable APIs.
- Get a preview of the course project and its real-world applications.
Content
1.1 Overview of RESTful API Principles
REST (Representational State Transfer) is a design pattern for building web services that are scalable, stateless, and easy to integrate. In this section, we’ll cover:
- What is REST? A set of architectural constraints for creating web services that communicate over HTTP.
-
Key Principles of REST:
- Statelessness: Each request from a client to the server must contain all the information needed to process it.
- Client-Server Architecture: Separating the client (frontend) from the server (backend) for better maintainability.
- Uniform Interface: Standard HTTP methods (GET, POST, PUT, DELETE) to perform CRUD operations.
- Resource-Based: APIs revolve around resources (e.g., users, products) identified by URLs.
- Hypermedia (HATEOAS): Responses include links to related resources for easier navigation.
- Why REST? Its simplicity, scalability, and compatibility with web standards make it a go-to choice for APIs.
- Real-World Examples: APIs like those for Twitter, GitHub, or Stripe follow REST principles.
1.2 Benefits of PHP 8 for Backend Development
PHP 8 brings modern features that make it a powerful choice for building APIs. We’ll explore:
-
New Features in PHP 8:
- JIT (Just-In-Time Compilation): Boosts performance for complex operations.
- Attributes: Simplifies metadata handling for validation and routing.
- Named Arguments: Enhances code readability and flexibility.
- Match Expressions: A cleaner alternative to switch statements.
- Union Types and Mixed Types: Improves type safety for robust code.
-
Why PHP 8 for APIs?
- Strong community support and a vast ecosystem of libraries.
- Improved performance and error handling compared to earlier versions.
- Compatibility with modern frameworks and tools like Composer.
- Use Case: How PHP 8 powers APIs for e-commerce, social media, and more.
1.3 Introduction to N-Tier Architecture
N-Tier architecture (or layered architecture) organizes code into distinct layers, each with a specific responsibility. This approach is perfect for building scalable and maintainable APIs. We’ll discuss:
-
Core Layers of N-Tier Architecture:
- Presentation Layer: Handles HTTP requests and responses (API endpoints).
- Business Logic Layer: Manages the application’s rules and processes.
- Data Access Layer: Interacts with the database or other storage systems.
-
Benefits of N-Tier:
- Separation of Concerns: Each layer focuses on one aspect, making code easier to maintain.
- Scalability: Layers can be scaled independently (e.g., adding more database servers).
- Testability: Isolated layers simplify unit and integration testing.
- Applying N-Tier to APIs: How this structure maps to controllers, services, and repositories in our project.
1.4 Course Objectives and Project Overview
This course is designed to take you from beginner to confident API developer. Here’s what we’ll achieve:
-
Course Objectives:
- Build a fully functional RESTful API using PHP 8.
- Implement N-Tier architecture for clean, scalable code.
- Learn best practices for security, testing, and deployment.
- Gain hands-on experience with real-world API features like authentication, pagination, and versioning.
-
Project Overview:
- We’ll build a Task Management API (or optionally an e-commerce API, depending on your preference).
- Features include user authentication, task creation/editing, filtering, and more.
- By the end, you’ll have a production-ready API with documented endpoints using OpenAPI/Swagger.
- Why This Project? It mirrors real-world applications, giving you practical skills for professional development.
Hands-On Activity
-
Task 1: Research RESTful APIs
- Find two real-world APIs (e.g., GitHub, Twitter) and explore their documentation.
- Identify at least three REST principles they follow (e.g., statelessness, uniform interface).
-
Task 2: Install PHP 8
- Download and install PHP 8 on your local machine (instructions for Windows, macOS, or Linux).
- Verify the installation by running
php -v
in your terminal.
-
Task 3: Sketch the Project
- Brainstorm a simple resource for our Task Management API (e.g., tasks, users).
- Write down three endpoints you think we’ll need (e.g.,
GET /tasks
,POST /tasks
).
Resources
- Official PHP 8 Documentation: https://www.php.net/manual/en/migration80.php
- REST API Tutorial: https://restfulapi.net/
- Introduction to N-Tier Architecture: https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/n-tier
- Postman for API Testing: https://www.postman.com/ # Module 2: Setting Up the Development Environment
Overview
Welcome to Module 2 of Build a Robust RESTful API with PHP 8, from Scratch! In this module, we’ll get our hands dirty by setting up the development environment needed to build our RESTful API. A solid setup is crucial for smooth coding, testing, and debugging. We’ll install PHP 8, configure a local development server, set up Composer for dependency management, and choose tools to streamline our workflow. By the end, you’ll have a fully functional environment ready for building our Task Management API.
Learning Objectives
- Install and verify PHP 8 and its required extensions.
- Set up a local development server using options like XAMPP, Laravel Valet, or Docker.
- Configure Composer for managing project dependencies.
- Select and configure an IDE and API testing tools for efficient development.
Content
2.1 Installing PHP 8 and Required Extensions
PHP 8 is the backbone of our project, and ensuring it’s properly installed is our first step. We’ll also include essential extensions for API development.
- Installing PHP 8:
-
Verifying Installation: Run
php -v
in your terminal to confirm PHP 8 is installed. -
Required Extensions:
-
pdo
andpdo_mysql
(orpdo_pgsql
for PostgreSQL) for database connectivity. -
mbstring
for handling multibyte strings. -
json
for encoding/decoding JSON responses. -
curl
for making HTTP requests (useful for testing or external API calls).
-
-
Enabling Extensions: Edit
php.ini
to enable extensions (e.g.,extension=pdo_mysql
). - Troubleshooting Tips: Common issues like missing extensions or PATH configuration.
2.2 Configuring a Local Development Server
A local server lets us test our API in a browser or with tools like Postman. We’ll explore three popular options:
-
Option 1: XAMPP
- Download and install XAMPP (https://www.apachefriends.org/) for an all-in-one Apache, MySQL, and PHP stack.
- Configure the document root to point to your project folder.
- Start Apache and MySQL from the XAMPP control panel.
-
Option 2: Laravel Valet (macOS)
- Install Valet via Composer (
composer global require laravel/valet
). - Configure a local domain (e.g.,
api.test
) for your project. - Lightweight and developer-friendly for PHP projects.
- Install Valet via Composer (
-
Option 3: Docker
- Set up a Docker container with PHP 8, Nginx, and a database (e.g., MySQL).
- Use a
docker-compose.yml
file to define services. - Example configuration:
version: '3' services: php: image: php:8.0-fpm volumes: - ./:/var/www/html ports: - "9000:9000" nginx: image: nginx:latest ports: - "80:80" volumes: - ./:/var/www/html - ./nginx.conf:/etc/nginx/conf.d/default.conf db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root
- Benefits: Portable, reproducible, and production-like environment.
Testing the Server: Create a simple
index.php
with<?php phpinfo();
and access it viahttp://localhost
.
2.3 Setting Up Composer for Dependency Management
Composer is PHP’s dependency manager, making it easy to include libraries and manage our project’s dependencies.
-
Installing Composer:
- Download from getcomposer.org and follow installation instructions.
- Verify with
composer --version
.
-
Creating a Project:
- Run
composer init
in your project folder to generate acomposer.json
file. - Example
composer.json
:
{ "name": "yourname/task-management-api", "description": "A RESTful API built with PHP 8", "require": { "php": "^8.0", "ext-pdo": "*" } }
- Run
-
Adding Dependencies:
- Install common libraries like
vlucas/phpdotenv
for environment variables:
composer require vlucas/phpdotenv
- Autoload classes using PSR-4 standards.
- Install common libraries like
Autoloading Setup: Configure
composer.json
to autoload your project’s classes:
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
- Run
composer dump-autoload
to generate the autoloader.
2.4 Choosing an IDE and Essential Tools
A good IDE and testing tools make development faster and more enjoyable.
-
Choosing an IDE:
- PHPStorm: Feature-rich with built-in support for PHP 8, Composer, and debugging.
- VS Code: Lightweight with PHP extensions (e.g., Intelephense, PHP Debug).
- Sublime Text: Fast and minimal, with PHP plugins.
-
Configuring the IDE:
- Enable PHP 8 support and set up code formatting (PSR-12 standards).
- Install debugging tools like Xdebug for breakpoints and profiling.
-
API Testing Tools:
- Postman: For sending HTTP requests and testing API endpoints.
- cURL: Command-line tool for quick API tests.
- Insomnia: A lightweight alternative to Postman.
-
Setting Up Postman:
- Download from postman.com.
- Create a workspace and a test collection for our API.
- Example request:
GET http://localhost/info
to test yourphpinfo()
page.
Hands-On Activity
-
Task 1: Install PHP 8 and Extensions
- Install PHP 8 on your system and verify with
php -v
. - Ensure
pdo_mysql
,mbstring
, andjson
extensions are enabled. - Create a simple
test.php
file to outputphpinfo()
and view it in a browser.
- Install PHP 8 on your system and verify with
-
Task 2: Set Up a Local Server
- Choose one server option (XAMPP, Valet, or Docker) and set it up.
- Create a project folder (e.g.,
task-management-api
) and serve a basicindex.php
file. - Access
http://localhost
to confirm the server is running.
-
Task 3: Initialize a Composer Project
- Install Composer and run
composer init
in your project folder. - Add
vlucas/phpdotenv
as a dependency. - Set up autoloading for a
src/
folder and test it with a simple class.
- Install Composer and run
-
Task 4: Explore Postman
- Install Postman and create a test request to
http://localhost
. - Save the request in a collection named “Task Management API.”
- Install Postman and create a test request to
Resources
- PHP 8 Installation Guide: https://www.php.net/manual/en/install.php
- Composer Documentation: https://getcomposer.org/doc/
- XAMPP Installation: https://www.apachefriends.org/
- Docker for PHP Development: https://www.docker.com/
- Postman Documentation: https://learning.postman.com/docs/getting-started/introduction/
Module 3: Understanding N-Tier Architecture
Overview
Hey there, welcome to Module 3 of Build a Robust RESTful API with PHP 8, from Scratch! This is where things start to get exciting—we’re diving into the N-Tier architecture, the secret sauce for building clean, scalable, and maintainable APIs. Think of it as organizing your code like a well-run kitchen: each station (or layer) has a specific job, and together they create something amazing. In this module, we’ll break down the layers of N-Tier, explore why it’s perfect for our Task Management API, and set up a project structure that’s ready to grow. By the end, you’ll have a clear mental map of how our API will come together and be itching to start coding!
Learning Objectives
- Grasp the core concepts of N-Tier architecture and how its layers work together.
- Discover why N-Tier is a game-changer for building APIs that scale and are easy to maintain.
- Map N-Tier layers to practical components of our RESTful API (like controllers, services, and repositories).
- Create a clean, organized folder structure for our Task Management API project.
- Get hands-on with practical examples to solidify your understanding.
Content
3.1 Core Concepts of N-Tier Architecture
N-Tier architecture (sometimes called layered architecture) is like stacking building blocks: each layer has a specific role, and they work together to keep your application organized and flexible. For our API, we’ll focus on three main layers, but we’ll also touch on optional ones for a complete picture.
-
Presentation Layer:
-
What it does: This is the front door of our API. It handles incoming HTTP requests (e.g., a client asking for
GET /tasks
) and sends back JSON responses. -
In our project: Controllers (e.g.,
TaskController
) live here, acting as the middleman between the client and the rest of the app. -
Example: When a user sends
POST /tasks
to create a task, the controller grabs the request data, passes it to the business logic, and returns a response like{"id": 1, "title": "Buy groceries"}
.
-
What it does: This is the front door of our API. It handles incoming HTTP requests (e.g., a client asking for
-
Business Logic Layer:
- What it does: This is the brain of the operation, where all the rules and logic live (e.g., “a task’s due date can’t be in the past”).
-
In our project: Service classes (e.g.,
TaskService
) handle tasks like validating input, calculating deadlines, or checking user permissions. -
Example: If a user tries to create a task with an invalid due date,
TaskService
catches it and throws an error before saving anything.
-
Data Access Layer:
- What it does: This layer talks to the database, handling all data operations (Create, Read, Update, Delete).
-
In our project: Repository classes (e.g.,
TaskRepository
) run queries likeINSERT INTO tasks
orSELECT * FROM tasks WHERE user_id = 1
. -
Example: When
TaskService
needs to save a task, it callsTaskRepository
, which executes the database query and returns the result.
-
Optional Layers:
-
Domain Layer: Defines core entities (e.g., a
Task
class with properties liketitle
anddue_date
). - Infrastructure Layer: Manages external services like logging, email sending, or third-party API calls.
-
Domain Layer: Defines core entities (e.g., a
- How Layers Interact: Think of it as a relay race. The controller passes the request to the service, which processes the logic and hands off to the repository to interact with the database. The response flows back the same way. This keeps everything tidy and prevents one layer from doing another’s job.
Real-World Analogy: Imagine a restaurant. The waiter (controller) takes your order, the chef (service) prepares the dish according to the recipe, and the pantry staff (repository) fetches ingredients from storage. Each role is distinct, but they work together to serve your meal.
3.2 Benefits of N-Tier for Scalability and Maintainability
Why go through the effort of splitting our code into layers? Because it makes our API robust and future-proof. Here’s why N-Tier is awesome:
- Separation of Concerns: Each layer focuses on one job, so your code stays clean and easy to understand. Need to change how tasks are saved? Update the repository without touching the controller.
- Scalability: Layers can scale independently. For example, if your database is getting hammered, you can add more database servers without rewriting the business logic.
- Maintainability: Fixing bugs or adding features is simpler when each layer is isolated. Want to switch from MySQL to PostgreSQL? Just update the repository layer.
- Testability: You can test services or repositories in isolation without spinning up the whole API. This makes unit testing a breeze.
-
Reusability: Business logic in services can be reused across endpoints or even different projects. For example, task validation logic could be shared between
POST /tasks
andPUT /tasks
.
Example: Imagine an e-commerce API. The OrderController
(presentation) handles a POST /orders
request. The OrderService
(business logic) checks if the customer has enough credit and calculates shipping. The OrderRepository
(data access) saves the order to the database. If you later want to add MongoDB support, you only update the repository, leaving the rest untouched.
3.3 Mapping N-Tier to a RESTful API Structure
Let’s bring N-Tier to life by mapping it to our Task Management API. Here’s how each layer will work in practice:
-
Presentation Layer (Controllers):
- Role: Handles HTTP requests and responses.
-
Example Endpoint:
GET /tasks
fetches all tasks for a user. - Code Snippet (TaskController.php):
namespace App\Controllers; use App\Services\TaskService; class TaskController { private $taskService; public function __construct(TaskService $taskService) { $this->taskService = $taskService; } public function getTasks() { $tasks = $this->taskService->getAllTasks(); header('Content-Type: application/json'); echo json_encode(['data' => $tasks]); } }
-
Business Logic Layer (Services):
- Role: Processes the logic behind API operations, like validating data or enforcing rules.
-
Example:
TaskService
ensures a task’s due date is valid and the user has permission to create it. - Code Snippet (TaskService.php):
namespace App\Services; use App\Repositories\TaskRepository; class TaskService { private $taskRepository; public function __construct(TaskRepository $taskRepository) { $this->taskRepository = $taskRepository; } public function createTask($data) { if (empty($data['title']) || strtotime($data['due_date']) < time()) { throw new \Exception('Invalid task title or due date'); } return $this->taskRepository->create($data); } }
-
Data Access Layer (Repositories):
- Role: Handles database queries and abstracts data operations.
-
Example:
TaskRepository
saves a task to thetasks
table. - Code Snippet (TaskRepository.php):
namespace App\Repositories; class TaskRepository { private $db; public function __construct(\PDO $db) { $this->db = $db; } public function create($data) { $stmt = $this->db->prepare('INSERT INTO tasks (title, due_date) VALUES (:title, :due_date)'); $stmt->execute(['title' => $data['title'], 'due_date' => $data['due_date']]); return $this->db->lastInsertId(); } }
-
Data Flow Example for POST /tasks:
- Client sends:
POST /tasks
with{"title": "Buy groceries", "due_date": "2025-10-15"}
. -
TaskController
receives the JSON, extracts data, and callsTaskService::createTask()
. -
TaskService
validates the title and due date, then callsTaskRepository::create()
. -
TaskRepository
executes anINSERT
query and returns the new task ID. -
TaskController
returns:{"status": "success", "id": 1}
.
- Client sends:
Another Example: For GET /tasks/1
, the flow is similar:
-
TaskController
callsTaskService::getTaskById(1)
. -
TaskService
callsTaskRepository::findById(1)
. -
TaskRepository
runsSELECT * FROM tasks WHERE id = 1
and returns the task. -
TaskController
formats and sends a JSON response.
3.4 Folder Structure and Project Organization
A clean folder structure keeps our project manageable as it grows. Here’s the structure for our Task Management API, designed to align with N-Tier principles:
task-management-api/
├── src/
│ ├── Controllers/ # Presentation Layer: Handles HTTP requests
│ │ └── TaskController.php
│ ├── Services/ # Business Logic Layer: Processes rules and logic
│ │ └── TaskService.php
│ ├── Repositories/ # Data Access Layer: Manages database operations
│ │ └── TaskRepository.php
│ ├── Entities/ # Domain models: Represents data like Task or User
│ │ └── Task.php
│ ├── Config/ # Configuration: Database settings, etc.
│ │ └── Database.php
│ ├── Middleware/ # Request preprocessing: Authentication, logging
│ │ └── AuthMiddleware.php
│ ├── Utils/ # Helpers: Logging, validation utilities
│ │ └── Logger.php
├── public/ # Web server root: API entry point
│ └── index.php
├── tests/ # Unit and integration tests
│ └── TaskServiceTest.php
├── vendor/ # Composer dependencies
├── .env # Environment variables (e.g., DB credentials)
├── composer.json # Composer configuration
└── README.md # Project documentation
-
Why This Structure?
- Clarity: Each folder corresponds to a layer or function, making it easy to find code.
- Scalability: Adding new features (e.g., user management) just means new files in existing folders.
- Alignment with N-Tier: Controllers, services, and repositories are clearly separated.
- Example File (Task.php):
namespace App\Entities;
class Task {
private $id;
private $title;
private $dueDate;
public function __construct($id, $title, $dueDate) {
$this->id = $id;
$this->title = $title;
$this->dueDate = $dueDate;
}
public function getTitle() {
return $this->title;
}
}
- Example .env File:
DB_HOST=localhost
DB_NAME=task_management
DB_USER=root
DB_PASS=
API_ENV=development
- Benefits: This structure supports autoloading, testing, and collaboration, keeping our project professional and organized.
Hands-On Activity
Let’s get practical to cement these concepts!
-
Task 1: Analyze a Real API’s Structure
- Visit the GitHub API (https://docs.github.com/en/rest) or Twitter API (https://developer.twitter.com/en/docs).
- Pick one endpoint (e.g.,
GET /repos/{owner}/{repo}
) and write a 100-word description of how it might use N-Tier architecture. Example: “The controller parses the URL, the service checks if the repo exists and the user is authorized, and the repository queries the database.”
-
Task 2: Set Up the Project Folder Structure
- In your
task-management-api
folder, create the folder structure shown above. - Create empty files for
TaskController.php
,TaskService.php
,TaskRepository.php
, andTask.php
. - Update
composer.json
to autoload these namespaces:
"autoload": { "psr-4": { "App\\Controllers\\": "src/Controllers/", "App\\Services\\": "src/Services/", "App\\Repositories\\": "src/Repositories/", "App\\Entities\\": "src/Entities/", "App\\Middleware\\": "src/Middleware/", "App\\Utils\\": "src/Utils/" } }
- Run
composer dump-autoload
and test by creating a simpleTask
class and loading it in a test script.
- In your
-
Task 3: Map an Endpoint’s Flow
- Choose an endpoint for our API (e.g.,
POST /tasks
orGET /tasks/{id}
). - Draw or write a flowchart showing how a request flows through the N-Tier layers. Example:
- Client → Controller (parses JSON) → Service (validates data) → Repository (saves to DB) → Response.
- Choose an endpoint for our API (e.g.,
-
Task 4: Create and Test a .env File
- Add a
.env
file with the example contents above. - Write a simple script to load it using
vlucas/phpdotenv
:
require 'vendor/autoload.php'; $dotenv = Dotenv\Dotenv::createImmutable(__DIR__); $dotenv->load(); echo $_ENV['DB_HOST']; // Should output "localhost"
- Run the script to confirm it works.
- Add a
-
Task 5: Brainstorm Additional Features
- Think of two additional features for our Task Management API (e.g., task categories, priorities).
- Write down how each feature would fit into the N-Tier layers (e.g., new controller methods, service logic, repository queries).
Resources
- N-Tier Architecture Guide: https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/n-tier
- PSR-4 Autoloading Standard: https://www.php-fig.org/psr/psr-4/
- PHP dotenv Documentation: https://github.com/vlucas/phpdotenv
- GitHub API Documentation: https://docs.github.com/en/rest
- REST API Design Best Practices: https://restfulapi.net/
Module 4: Project Setup and Initial Configuration
Overview
Welcome to Module 4 of Build a Robust RESTful API with PHP 8, from Scratch! Now that we’ve got our development environment ready and understand the N-Tier architecture, it’s time to roll up our sleeves and start building the foundation of our Task Management API. In this module, we’ll set up the project structure, configure essential components like environment variables, implement a basic routing system, and create a project boilerplate to kickstart our API. By the end, you’ll have a working skeleton for the API, ready to handle requests and grow with new features. Let’s dive in and get coding!
Learning Objectives
- Initialize a new PHP project using Composer.
- Set up environment variables for secure configuration management.
- Implement a simple routing system to handle API requests.
- Create a project boilerplate that aligns with N-Tier architecture.
- Test the initial setup with a basic endpoint.
Content
4.1 Creating a New PHP Project with Composer
Composer is our go-to tool for managing dependencies and autoloading, ensuring our project stays organized and scalable.
-
Initializing the Project:
- In your
task-management-api
folder, run:
composer init
- Follow the prompts to set up
composer.json
. Example:
{ "name": "yourname/task-management-api", "description": "A RESTful Task Management API built with PHP 8", "type": "project", "require": { "php": "^8.0", "ext-pdo": "*", "vlucas/phpdotenv": "^5.5" }, "autoload": { "psr-4": { "App\\Controllers\\": "src/Controllers/", "App\\Services\\": "src/Services/", "App\\Repositories\\": "src/Repositories/", "App\\Entities\\": "src/Entities/", "App\\Middleware\\": "src/Middleware/", "App\\Utils\\": "src/Utils/" } } }
- Run
composer install
to generate thevendor/
folder and autoloader.
- In your
-
Why Composer?
- Manages dependencies like
vlucas/phpdotenv
for environment variables. - Autoloads classes following PSR-4 standards, saving us from manual
require
statements.
- Manages dependencies like
-
Testing Autoloading:
- Create a test file
src/Utils/HelloWorld.php
:
namespace App\Utils; class HelloWorld { public function sayHello() { return "Hello, API World!"; } }
- Test it in
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php'; use App\Utils\HelloWorld; $hello = new HelloWorld(); echo $hello->sayHello();
- Access
http://localhost
to see “Hello, API World!”.
- Create a test file
4.2 Setting Up Configuration Files
Environment variables keep sensitive data (like database credentials) secure and make our API adaptable to different environments (development, production).
-
Using
vlucas/phpdotenv
:- Ensure it’s installed (
composer require vlucas/phpdotenv
). - Create a
.env
file in the project root:
APP_ENV=development APP_NAME=TaskManagementAPI DB_HOST=localhost DB_NAME=task_management DB_USER=root DB_PASS= API_KEY=your-secret-key
- Create a
src/Config/Database.php
to load database settings:
namespace App\Config; use Dotenv\Dotenv; class Database { private static $instance = null; private $connection; private function __construct() { $dotenv = Dotenv::createImmutable(__DIR__ . '/../../'); $dotenv->load(); $dsn = "mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']}"; $this->connection = new \PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [ \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC ]); } public static function getInstance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance->connection; } }
- Ensure it’s installed (
-
Why This Approach?
- Singleton pattern ensures a single database connection.
-
.env
keeps sensitive data out of version control. - PDO provides secure, flexible database access.
-
Testing the Configuration:
- Create a test script
test_db.php
:
require_once __DIR__ . '/vendor/autoload.php'; use App\Config\Database; try { $db = Database::getInstance(); echo "Database connection successful!"; } catch (\Exception $e) { echo "Connection failed: " . $e->getMessage(); }
- Run
php test_db.php
to verify the connection.
- Create a test script
4.3 Implementing a Basic Routing System
A routing system directs incoming HTTP requests to the right controller. For simplicity, we’ll build a lightweight router without a framework.
-
Creating the Router:
- Create
src/Routing/Router.php
:
namespace App\Routing; class Router { private $routes = []; public function get($path, $callback) { $this->routes['GET'][$path] = $callback; } public function post($path, $callback) { $this->routes['POST'][$path] = $callback; } public function dispatch() { $method = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); foreach ($this->routes[$method] ?? [] as $route => $callback) { $pattern = preg_replace('#\{([\w]+)\}#', '([^/]+)', $route); $pattern = "#^$pattern$#"; if (preg_match($pattern, $uri, $matches)) { array_shift($matches); return call_user_func_array($callback, $matches); } } http_response_code(404); echo json_encode(['error' => 'Route not found']); } }
- Create
-
Setting Up the Entry Point:
- Update
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php'; use App\Routing\Router; $router = new Router(); // Define a test route $router->get('/api/test', function () { return json_encode(['message' => 'Welcome to the Task Management API!']); }); $router->dispatch();
- Update
-
How It Works:
- The router matches HTTP methods (GET, POST) and URLs to callbacks.
- Supports dynamic routes (e.g.,
/tasks/{id}
) using regex. - Returns a 404 JSON response for unmatched routes.
-
Testing the Router:
- Start your local server (e.g.,
php -S localhost:8000 -t public
). - Use Postman to send a
GET
request tohttp://localhost:8000/api/test
. - Expect:
{"message": "Welcome to the Task Management API!"}
.
- Start your local server (e.g.,
4.4 Establishing a Project Boilerplate
Let’s tie everything together with a boilerplate that sets up our N-Tier structure and prepares for future modules.
-
Update Folder Structure:
- Ensure the structure from Module 3 is in place:
task-management-api/ ├── src/ │ ├── Config/ │ │ └── Database.php │ ├── Controllers/ │ │ └── TaskController.php │ ├── Services/ │ │ └── TaskService.php │ ├── Repositories/ │ │ └── TaskRepository.php │ ├── Entities/ │ │ └── Task.php │ ├── Routing/ │ │ └── Router.php │ ├── Middleware/ │ └── Utils/ ├── public/ │ └── index.php ├── vendor/ ├── .env ├── composer.json └── README.md
-
Create a Basic TaskController:
- In
src/Controllers/TaskController.php
:
namespace App\Controllers; class TaskController { public function index() { return json_encode(['data' => [], 'message' => 'Tasks endpoint']); } }
- In
-
Add Routes for Tasks:
- Update
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php'; use App\Routing\Router; use App\Controllers\TaskController; $router = new Router(); $router->get('/api/test', function () { return json_encode(['message' => 'Welcome to the Task Management API!']); }); $router->get('/api/tasks', [new TaskController(), 'index']); $router->dispatch();
- Update
-
Create a README:
- Add a
README.md
:
# Task Management API A RESTful API built with PHP 8 and N-Tier architecture. ## Setup 1. Run `composer install` to install dependencies. 2. Copy `.env.example` to `.env` and configure your database. 3. Start the server: `php -S localhost:8000 -t public`. 4. Test with Postman: `GET http://localhost:8000/api/test`. ## Endpoints - `GET /api/test`: Welcome message - `GET /api/tasks`: List tasks (WIP)
- Add a
Hands-On Activity
Let’s make sure everything’s working and get comfortable with the setup!
-
Task 1: Initialize the Composer Project
- Run
composer init
in your project folder and configurecomposer.json
as shown above. - Install
vlucas/phpdotenv
and verify autoloading with theHelloWorld
test.
- Run
-
Task 2: Set Up Environment Variables
- Create a
.env
file with the example contents. - Implement and test
Database.php
using the test script provided. - Try connecting to a local MySQL database (create a
task_management
database if needed).
- Create a
-
Task 3: Test the Routing System
- Implement the
Router.php
class and updateindex.php
. - Test
GET /api/test
in Postman and verify the JSON response. - Add a new route
GET /api/health
that returns{"status": "ok"}
and test it.
- Implement the
-
Task 4: Build the Boilerplate
- Ensure the folder structure is complete.
- Create
TaskController.php
and add it to the router. - Test
GET /api/tasks
in Postman and verify the response.
-
Task 5: Enhance the README
- Update
README.md
with setup instructions and a list of current endpoints. - Add a section for “Project Goals” (e.g., “Build a scalable API for task management”).
- Update
Resources
- Composer Documentation: https://getcomposer.org/doc/
- PHP dotenv Library: https://github.com/vlucas/phpdotenv
- PDO Documentation: https://www.php.net/manual/en/book.pdo.php
- Postman for API Testing: https://www.postman.com/
- PSR-4 Autoloading Standard: https://www.php-fig.org/psr/psr-4/
Module 5: Building the Data Access Layer
Overview
Welcome to Module 5 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now diving into the Data Access Layer—the foundation that connects our Task Management API to the database. This layer handles all database interactions, keeping them organized and isolated from the rest of the application. In this module, we’ll design a database for our API, set up PDO for secure connections, create a base repository class, and implement basic CRUD operations for tasks. By the end, you’ll have a functional data layer ready to support our API’s core features. Let’s get started!
Learning Objectives
- Design a simple database schema for the Task Management API.
- Configure PDO for secure and efficient database connections.
- Create a base repository class to standardize data operations.
- Implement CRUD operations for the Task entity in the Data Access Layer.
- Test database interactions to ensure everything works smoothly.
Content
5.1 Introduction to Database Design for APIs
A well-designed database is critical for a performant and scalable API. For our Task Management API, we’ll create a simple schema to store tasks and users, keeping it extensible for future features.
-
Key Considerations:
- Normalized Structure: Avoid data redundancy while keeping queries efficient.
- Scalability: Design tables to handle growth (e.g., indexing for performance).
- API Needs: Focus on resources (tasks, users) that map to RESTful endpoints.
-
Proposed Schema:
- Users Table: Stores user information for authentication and task ownership.
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
- Tasks Table: Stores task details, linked to users.
CREATE TABLE tasks ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, due_date DATE, status ENUM('pending', 'completed') DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) );
-
Why This Schema?
-
users
supports authentication and task ownership. -
tasks
includes essential fields (title, description, due_date, status) with a foreign key to link tasks to users. - Indexes (e.g., on
user_id
) ensure fast queries.
-
-
Setup Instructions:
- Create a MySQL database named
task_management
. - Run the above SQL to create
users
andtasks
tables. - Insert a test user:
INSERT INTO users (username, email, password) VALUES ('testuser', 'test@example.com', 'hashed_password');
- Create a MySQL database named
5.2 Setting Up a Database (MySQL/PostgreSQL)
We’ll use MySQL for this course, but the setup is similar for PostgreSQL. PDO will abstract database-specific details for flexibility.
-
Installing MySQL:
- If not already installed (e.g., via XAMPP or Docker), download MySQL Community Server (https://dev.mysql.com/downloads/).
- For Docker, use the
mysql:8.0
image from Module 2’sdocker-compose.yml
.
-
Creating the Database:
- Connect to MySQL:
mysql -u root -p
. - Create the database:
CREATE DATABASE task_management;
- Verify with
SHOW DATABASES;
.
- Connect to MySQL:
-
Alternative: PostgreSQL Setup:
- Install PostgreSQL (https://www.postgresql.org/download/) or use Docker (
postgres:latest
). - Create the database:
createdb task_management
. - Adapt the schema SQL (e.g., replace
AUTO_INCREMENT
withSERIAL
).
- Install PostgreSQL (https://www.postgresql.org/download/) or use Docker (
5.3 Configuring PDO for Secure Database Connections
PDO (PHP Data Objects) provides a secure, portable way to interact with databases. We’ll enhance our Database.php
from Module 4 to ensure robust connections.
-
Update
src/Config/Database.php
:
namespace App\Config;
use Dotenv\Dotenv;
use PDOException;
class Database {
private static $instance = null;
private $connection;
private function __construct() {
$dotenv = Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();
try {
$dsn = "mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']};charset=utf8mb4";
$this->connection = new \PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false
]);
} catch (PDOException $e) {
throw new PDOException("Connection failed: " . $e->getMessage());
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance->connection;
}
}
-
Why This Setup?
- Singleton Pattern: Ensures one connection per request, reducing overhead.
-
Error Handling: PDO’s
ERRMODE_EXCEPTION
catches errors early. - Security: Disables emulated prepares to prevent SQL injection.
-
Charset:
utf8mb4
supports full Unicode (e.g., emojis in task descriptions).
-
Testing the Connection:
- Create
test_db_connection.php
:
require_once __DIR__ . '/vendor/autoload.php'; use App\Config\Database; try { $db = Database::getInstance(); $stmt = $db->query('SELECT 1'); echo "Database connection successful!"; } catch (\Exception $e) { echo "Error: " . $e->getMessage(); }
- Run
php test_db_connection.php
to confirm.
- Create
5.4 Creating a Base Repository Class
A base repository class provides reusable methods for common database operations, ensuring consistency across all repositories.
-
Create
src/Repositories/BaseRepository.php
:
namespace App\Repositories;
use App\Config\Database;
abstract class BaseRepository {
protected $db;
public function __construct() {
$this->db = Database::getInstance();
}
public function findById($table, $id) {
$stmt = $this->db->prepare("SELECT * FROM $table WHERE id = :id");
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
public function findAll($table) {
$stmt = $this->db->query("SELECT * FROM $table");
return $stmt->fetchAll();
}
public function delete($table, $id) {
$stmt = $this->db->prepare("DELETE FROM $table WHERE id = :id");
return $stmt->execute(['id' => $id]);
}
}
-
Why a Base Repository?
- Reduces code duplication for common operations (find, delete).
- Provides a foundation for specific repositories (e.g.,
TaskRepository
). - Ensures consistent database interaction patterns.
5.5 Implementing CRUD Operations for Tasks
Let’s create a TaskRepository
to handle task-specific database operations, extending the base repository.
-
Create
src/Repositories/TaskRepository.php
:
namespace App\Repositories;
use App\Entities\Task;
class TaskRepository extends BaseRepository {
public function create(array $data) {
$stmt = $this->db->prepare(
'INSERT INTO tasks (user_id, title, description, due_date, status)
VALUES (:user_id, :title, :description, :due_date, :status)'
);
$stmt->execute([
'user_id' => $data['user_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
]);
return $this->db->lastInsertId();
}
public function update($id, array $data) {
$stmt = $this->db->prepare(
'UPDATE tasks
SET title = :title, description = :description, due_date = :due_date, status = :status
WHERE id = :id'
);
return $stmt->execute([
'id' => $id,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
]);
}
public function findByUserId($userId) {
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE user_id = :user_id');
$stmt->execute(['user_id' => $userId]);
return $stmt->fetchAll();
}
}
-
Create
src/Entities/Task.php
:
namespace App\Entities;
class Task {
private $id;
private $userId;
private $title;
private $description;
private $dueDate;
private $status;
private $createdAt;
public function __construct($data) {
$this->id = $data['id'] ?? null;
$this->userId = $data['user_id'] ?? null;
$this->title = $data['title'] ?? null;
$this->description = $data['description'] ?? null;
$this->dueDate = $data['due_date'] ?? null;
$this->status = $data['status'] ?? 'pending';
$this->createdAt = $data['created_at'] ?? null;
}
public function toArray() {
return [
'id' => $this->id,
'user_id' => $this->userId,
'title' => $this->title,
'description' => $this->description,
'due_date' => $this->dueDate,
'status' => $this->status,
'created_at' => $this->createdAt
];
}
}
-
Explanation:
-
TaskRepository
extendsBaseRepository
to inherit generic methods (findById
,findAll
,delete
). -
create
andupdate
handle task-specific fields with prepared statements for security. -
findByUserId
retrieves tasks for a specific user, useful forGET /tasks?user_id=1
. -
Task
entity maps database rows to objects for clean data handling.
-
Hands-On Activity
Let’s put the Data Access Layer to work with practical tasks!
-
Task 1: Set Up the Database
- Create the
task_management
database in MySQL or PostgreSQL. - Run the SQL scripts to create
users
andtasks
tables. - Insert a test user and a test task:
INSERT INTO tasks (user_id, title, description, due_date) VALUES (1, 'Test Task', 'This is a test task', '2025-10-20');
- Create the
-
Task 2: Test the Database Connection
- Implement
Database.php
and run thetest_db_connection.php
script. - Verify the connection works by checking for “Database connection successful!”.
- Implement
-
Task 3: Implement and Test BaseRepository
- Create
BaseRepository.php
and test itsfindAll
method:
require_once __DIR__ . '/vendor/autoload.php'; use App\Repositories\BaseRepository; class TestRepository extends BaseRepository {} $repo = new TestRepository(); print_r($repo->findAll('tasks'));
- Run the script to see the test task from Task 1.
- Create
-
Task 4: Test TaskRepository
- Implement
TaskRepository.php
andTask.php
. - Create a test script
test_task_repo.php
:
require_once __DIR__ . '/vendor/autoload.php'; use App\Repositories\TaskRepository; $repo = new TaskRepository(); $data = [ 'user_id' => 1, 'title' => 'New Task', 'description' => 'Test description', 'due_date' => '2025-10-25' ]; $id = $repo->create($data); echo "Created task ID: $id\n"; print_r($repo->findById('tasks', $id));
- Run
php test_task_repo.php
to verify task creation and retrieval.
- Implement
-
Task 5: Add a Custom Repository Method
- Add a method to
TaskRepository
to find tasks by status (e.g.,findByStatus($status)
). - Test it with a script that retrieves all
pending
tasks.
- Add a method to
Resources
- MySQL Documentation: https://dev.mysql.com/doc/
- PostgreSQL Documentation: https://www.postgresql.org/docs/
- PDO Documentation: https://www.php.net/manual/en/book.pdo.php
- Database Normalization Guide: https://www.sqlshack.com/what-is-database-normalization/
- PHP Prepared Statements: https://www.php.net/manual/en/pdo.prepared-statements.php # Module 6: Designing the Data Models
Overview
Welcome to Module 6 of Build a Robust RESTful API with PHP 8, from Scratch! We're now ready to shape the heart of our Task Management API by designing data models. These models represent the core entities of our application (like tasks and users) and ensure our data is structured, consistent, and easy to work with across layers. In this module, we’ll define database schemas, create entity classes to map to those schemas, and implement basic CRUD operations using our TaskRepository
. By the end, you’ll have a solid data model foundation that integrates seamlessly with the Data Access Layer. Let’s dive in and bring our API’s data to life!
Learning Objectives
- Define clear and efficient database schemas for the Task Management API.
- Create entity classes to represent data models in PHP.
- Map database tables to PHP objects for consistent data handling.
- Implement and test basic CRUD operations using entity classes and the repository.
- Understand how data models fit into the N-Tier architecture.
Content
6.1 Defining Database Schemas for the API
A well-designed database schema ensures our API is efficient, scalable, and aligned with RESTful principles. For our Task Management API, we’ll refine the schema introduced in Module 5 to support tasks and users, with room for future expansion.
-
Refined Schema:
- Users Table: Stores user data for authentication and task ownership.
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );
- Tasks Table: Stores task details, linked to users.
CREATE TABLE tasks ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, due_date DATE, status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );
-
Key Features:
-
Users Table: Includes
updated_at
for tracking changes andUNIQUE
constraints onusername
andemail
for data integrity. -
Tasks Table: Adds
in_progress
to thestatus
enum for more flexibility and usesON DELETE CASCADE
to remove tasks if a user is deleted. -
Indexes: Foreign key on
user_id
and index onstatus
for faster queries (e.g.,CREATE INDEX idx_status ON tasks(status);
).
-
Users Table: Includes
-
Why This Design?
- Supports core API functionality (task creation, user management).
- Normalizes data to avoid redundancy while keeping queries simple.
- Prepares for future features like task categories or priorities.
-
Setup Instructions:
- Update your
task_management
database with the new schema:
ALTER TABLE tasks MODIFY status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending'; ALTER TABLE users ADD updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; ALTER TABLE tasks ADD updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; CREATE INDEX idx_status ON tasks(status);
- Insert sample data:
INSERT INTO users (username, email, password) VALUES ('john_doe', 'john@example.com', 'hashed_password'); INSERT INTO tasks (user_id, title, description, due_date, status) VALUES (1, 'Plan meeting', 'Organize team sync', '2025-10-20', 'pending');
- Update your
6.2 Creating Entity Classes to Represent Data Models
Entity classes map database rows to PHP objects, making data handling clean and object-oriented. We’ll create User
and Task
entities to represent our tables.
-
Update
src/Entities/Task.php
:
namespace App\Entities;
class Task {
private $id;
private $userId;
private $title;
private $description;
private $dueDate;
private $status;
private $createdAt;
private $updatedAt;
public function __construct(array $data = []) {
$this->id = $data['id'] ?? null;
$this->userId = $data['user_id'] ?? null;
$this->title = $data['title'] ?? null;
$this->description = $data['description'] ?? null;
$this->dueDate = $data['due_date'] ?? null;
$this->status = $data['status'] ?? 'pending';
$this->createdAt = $data['created_at'] ?? null;
$this->updatedAt = $data['updated_at'] ?? null;
}
// Getters
public function getId() { return $this->id; }
public function getUserId() { return $this->userId; }
public function getTitle() { return $this->title; }
public function getDescription() { return $this->description; }
public function getDueDate() { return $this->dueDate; }
public function getStatus() { return $this->status; }
public function getCreatedAt() { return $this->createdAt; }
public function getUpdatedAt() { return $this->updatedAt; }
// Setters
public function setTitle(string $title) { $this->title = $title; }
public function setDescription(?string $description) { $this->description = $description; }
public function setDueDate(?string $dueDate) { $this->dueDate = $dueDate; }
public function setStatus(string $status) { $this->status = $status; }
public function toArray(): array {
return [
'id' => $this->id,
'user_id' => $this->userId,
'title' => $this->title,
'description' => $this->description,
'due_date' => $this->dueDate,
'status' => $this->status,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt
];
}
}
-
Create
src/Entities/User.php
:
namespace App\Entities;
class User {
private $id;
private $username;
private $email;
private $password;
private $createdAt;
private $updatedAt;
public function __construct(array $data = []) {
$this->id = $data['id'] ?? null;
$this->username = $data['username'] ?? null;
$this->email = $data['email'] ?? null;
$this->password = $data['password'] ?? null;
$this->createdAt = $data['created_at'] ?? null;
$this->updatedAt = $data['updated_at'] ?? null;
}
// Getters
public function getId() { return $this->id; }
public function getUsername() { return $this->username; }
public function getEmail() { return $this->email; }
public function getCreatedAt() { return $this->createdAt; }
public function getUpdatedAt() { return $this->updatedAt; }
// Setters
public function setUsername(string $username) { $this->username = $username; }
public function setEmail(string $email) { $this->email = $email; }
public function setPassword(string $password) { $this->password = $password; }
public function toArray(): array {
return [
'id' => $this->id,
'username' => $this->username,
'email' => $this->email,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt
];
}
}
-
Why Entity Classes?
- Encapsulate data with getters/setters for controlled access.
-
toArray()
method prepares data for JSON responses. - Type hints and nullable properties align with PHP 8’s features for robustness.
- Example Usage:
$taskData = ['id' => 1, 'user_id' => 1, 'title' => 'Plan meeting', 'status' => 'pending'];
$task = new Task($taskData);
echo $task->getTitle(); // Outputs: Plan meeting
print_r($task->toArray()); // Outputs array for JSON encoding
6.3 Mapping Database Tables to PHP Objects
To ensure seamless integration, we’ll update TaskRepository
to return Task
objects instead of raw arrays, and create a UserRepository
for users.
-
Update
src/Repositories/TaskRepository.php
:
namespace App\Repositories;
use App\Entities\Task;
class TaskRepository extends BaseRepository {
public function create(array $data): Task {
$stmt = $this->db->prepare(
'INSERT INTO tasks (user_id, title, description, due_date, status)
VALUES (:user_id, :title, :description, :due_date, :status)'
);
$stmt->execute([
'user_id' => $data['user_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
]);
$id = $this->db->lastInsertId();
return $this->findById('tasks', $id);
}
public function update($id, array $data): bool {
$stmt = $this->db->prepare(
'UPDATE tasks
SET title = :title, description = :description, due_date = :due_date, status = :status
WHERE id = :id'
);
return $stmt->execute([
'id' => $id,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
]);
}
public function findById(string $table, $id): ?Task {
$data = parent::findById($table, $id);
return $data ? new Task($data) : null;
}
public function findAll(string $table): array {
$rows = parent::findAll($table);
return array_map(fn($row) => new Task($row), $rows);
}
public function findByUserId($userId): array {
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE user_id = :user_id');
$stmt->execute(['user_id' => $userId]);
$rows = $stmt->fetchAll();
return array_map(fn($row) => new Task($row), $rows);
}
}
-
Create
src/Repositories/UserRepository.php
:
namespace App\Repositories;
use App\Entities\User;
class UserRepository extends BaseRepository {
public function create(array $data): User {
$stmt = $this->db->prepare(
'INSERT INTO users (username, email, password)
VALUES (:username, :email, :password)'
);
$stmt->execute([
'username' => $data['username'],
'email' => $data['email'],
'password' => password_hash($data['password'], PASSWORD_DEFAULT)
]);
$id = $this->db->lastInsertId();
return $this->findById('users', $id);
}
public function findById(string $table, $id): ?User {
$data = parent::findById($table, $id);
return $data ? new User($data) : null;
}
public function findAll(string $table): array {
$rows = parent::findAll($table);
return array_map(fn($row) => new User($row), $rows);
}
public function findByEmail(string $email): ?User {
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$data = $stmt->fetch();
return $data ? new User($data) : null;
}
}
-
How It Works:
-
TaskRepository
andUserRepository
returnTask
andUser
objects, respectively, for type-safe data handling. -
password_hash
inUserRepository
secures passwords (we’ll cover authentication in Module 10). -
array_map
converts raw database rows into entity objects.
-
6.4 Implementing Basic CRUD Operations
We’ve already implemented CRUD in TaskRepository
and UserRepository
. Let’s test them to ensure they work with our entities.
-
Example Test Script (
test_crud.php
):
require_once __DIR__ . '/vendor/autoload.php';
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
// Test User CRUD
$userRepo = new UserRepository();
$userData = [
'username' => 'jane_doe',
'email' => 'jane@example.com',
'password' => 'secret123'
];
$user = $userRepo->create($userData);
echo "Created User: " . $user->getUsername() . "\n";
$user = $userRepo->findByEmail('jane@example.com');
echo "Found User: " . $user->getEmail() . "\n";
// Test Task CRUD
$taskRepo = new TaskRepository();
$taskData = [
'user_id' => $user->getId(),
'title' => 'Write report',
'description' => 'Draft quarterly report',
'due_date' => '2025-10-30',
'status' => 'in_progress'
];
$task = $taskRepo->create($taskData);
echo "Created Task ID: " . $task->getId() . "\n";
$task->setStatus('completed');
$taskRepo->update($task->getId(), $task->toArray());
$updatedTask = $taskRepo->findById('tasks', $task->getId());
echo "Updated Task Status: " . $updatedTask->getStatus() . "\n";
$tasks = $taskRepo->findByUserId($user->getId());
echo "User Tasks: " . count($tasks) . "\n";
- Expected Output:
Created User: jane_doe
Found User: jane@example.com
Created Task ID: 2
Updated Task Status: completed
User Tasks: 1
Hands-On Activity
Let’s solidify our data models with practical tasks!
-
Task 1: Update the Database Schema
- Apply the updated schema to your
task_management
database. - Add the sample user and task data using the provided SQL.
- Verify with
SELECT * FROM tasks;
in MySQL.
- Apply the updated schema to your
-
Task 2: Implement Entity Classes
- Create
Task.php
andUser.php
as shown. - Test the
Task
class by creating a new instance and callingtoArray()
in a script.
- Create
-
Task 3: Update and Test Repositories
- Update
TaskRepository.php
and createUserRepository.php
. - Run the
test_crud.php
script to verify CRUD operations. - Debug any errors (e.g., check
.env
credentials or table structure).
- Update
-
Task 4: Add a Custom Entity Method
- Add a method to
Task.php
(e.g.,isOverdue()
to check ifdue_date
is past today). - Test it in a script:
$task = new Task(['due_date' => '2025-10-01']); echo $task->isOverdue() ? "Overdue" : "Not overdue";
- Add a method to
-
Task 5: Extend UserRepository
- Add a method to
UserRepository
to update a user’s email. - Test it by updating a user’s email and retrieving the updated user.
- Add a method to
Resources
- PHP 8 Classes and Objects: https://www.php.net/manual/en/language.oop5.php
- MySQL Foreign Keys: https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html
- PDO Prepared Statements: https://www.php.net/manual/en/pdo.prepared-statements.php
- Password Hashing in PHP: https://www.php.net/manual/en/function.password-hash.php
- Database Design Best Practices: https://www.sqlshack.com/database-design-best-practices/ # Module 7: Developing the Business Logic Layer
Overview
Welcome to Module 7 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now stepping into the Business Logic Layer, the brain of our Task Management API. This layer is where the magic happens—handling the rules, validations, and workflows that make our API smart and reliable. In this module, we’ll structure the service layer, create service classes to manage task and user logic, encapsulate business rules for reusability, and implement robust validation and error handling. By the end, you’ll have a fully functional business logic layer that connects our controllers to the data layer, ready to power our API’s core features. Let’s dive in and make our API think!
Learning Objectives
- Understand the role of the Business Logic Layer in N-Tier architecture.
- Structure the service layer for clean, maintainable code.
- Create service classes for tasks and users to handle core API logic.
- Encapsulate business rules to ensure reusability and consistency.
- Implement input validation and error handling to make the API robust.
Content
7.1 Structuring the Service Layer for Business Rules
The Business Logic Layer sits between the Presentation Layer (controllers) and the Data Access Layer (repositories). It’s responsible for processing requests, applying rules, and coordinating data operations.
-
Role of the Service Layer:
- Validates input data (e.g., ensuring a task’s due date is valid).
- Enforces business rules (e.g., only authenticated users can create tasks).
- Coordinates between controllers and repositories for smooth data flow.
-
Why a Service Layer?
- Keeps controllers thin by offloading logic.
- Ensures business rules are centralized and reusable.
- Makes testing easier by isolating logic from HTTP concerns.
-
Structure:
- Create service classes (e.g.,
TaskService
,UserService
) insrc/Services/
. - Each service handles one domain (tasks, users) and depends on repositories for data access.
- Create service classes (e.g.,
-
Example Workflow:
- A
POST /tasks
request hits the controller, which callsTaskService::createTask()
. -
TaskService
validates the input and callsTaskRepository::create()
. - The result flows back to the controller for a JSON response.
- A
7.2 Creating Service Classes to Handle API Logic
Let’s build TaskService
and UserService
to manage task and user operations, respectively.
-
Create
src/Services/TaskService.php
:
namespace App\Services;
use App\Entities\Task;
use App\Repositories\TaskRepository;
class TaskService {
private $taskRepository;
public function __construct(TaskRepository $taskRepository) {
$this->taskRepository = $taskRepository;
}
public function createTask(array $data, int $userId): Task {
// Validate input
if (empty($data['title']) || strlen($data['title']) < 3) {
throw new \InvalidArgumentException('Task title must be at least 3 characters long');
}
if (isset($data['due_date']) && strtotime($data['due_date']) < time()) {
throw new \InvalidArgumentException('Due date cannot be in the past');
}
if (isset($data['status']) && !in_array($data['status'], ['pending', 'in_progress', 'completed'])) {
throw new \InvalidArgumentException('Invalid task status');
}
// Prepare data
$taskData = [
'user_id' => $userId,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
];
return $this->taskRepository->create($taskData);
}
public function updateTask(int $id, array $data, int $userId): bool {
// Validate input
if (isset($data['title']) && strlen($data['title']) < 3) {
throw new \InvalidArgumentException('Task title must be at least 3 characters long');
}
if (isset($data['due_date']) && strtotime($data['due_date']) < time()) {
throw new \InvalidArgumentException('Due date cannot be in the past');
}
$task = $this->taskRepository->findById('tasks', $id);
if (!$task || $task->getUserId() !== $userId) {
throw new \RuntimeException('Task not found or unauthorized');
}
return $this->taskRepository->update($id, $data);
}
public function getTasksByUser(int $userId): array {
return $this->taskRepository->findByUserId($userId);
}
public function deleteTask(int $id, int $userId): bool {
$task = $this->taskRepository->findById('tasks', $id);
if (!$task || $task->getUserId() !== $userId) {
throw new \RuntimeException('Task not found or unauthorized');
}
return $this->taskRepository->delete('tasks', $id);
}
}
-
Create
src/Services/UserService.php
:
namespace App\Services;
use App\Entities\User;
use App\Repositories\UserRepository;
class UserService {
private $userRepository;
public function __construct(UserRepository $userRepository) {
$this->userRepository = $userRepository;
}
public function createUser(array $data): User {
// Validate input
if (empty($data['username']) || strlen($data['username']) < 3) {
throw new \InvalidArgumentException('Username must be at least 3 characters long');
}
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address');
}
if (empty($data['password']) || strlen($data['password']) < 6) {
throw new \InvalidArgumentException('Password must be at least 6 characters long');
}
// Check for existing user
if ($this->userRepository->findByEmail($data['email'])) {
throw new \RuntimeException('Email already exists');
}
return $this->userRepository->create($data);
}
public function getUserById(int $id): ?User {
return $this->userRepository->findById('users', $id);
}
public function getUserByEmail(string $email): ?User {
return $this->userRepository->findByEmail($email);
}
}
-
Key Features:
- Dependency Injection: Services receive repositories via constructor, keeping them decoupled.
- Validation: Checks for valid input (e.g., title length, email format) before passing to repositories.
- Authorization: Ensures users can only modify their own tasks.
- Error Handling: Throws exceptions for invalid data or unauthorized actions.
7.3 Encapsulating Business Logic for Reusability
Encapsulation ensures business rules are centralized and reusable across endpoints.
-
Examples of Business Rules:
- Tasks must have a title of at least 3 characters.
- Due dates cannot be in the past.
- Only the task owner can update or delete a task.
- Email addresses must be unique and valid.
-
Reusability Benefits:
- The same validation logic (e.g., title length) is used for both
createTask
andupdateTask
. - Services can be called by multiple controllers or even future CLI scripts.
- The same validation logic (e.g., title length) is used for both
-
Example:
-
TaskService::createTask
can be reused forPOST /tasks
and a futurePOST /batch-tasks
. -
UserService::getUserByEmail
supports login and user lookup endpoints.
-
7.4 Handling Validations and Error Handling
Robust validation and error handling make our API reliable and user-friendly.
-
Validation Techniques:
- Use PHP’s built-in functions (e.g.,
filter_var
for emails). - Check for required fields and valid formats in services.
- Leverage PHP 8’s type hints for stricter data validation.
- Use PHP’s built-in functions (e.g.,
-
Error Handling:
- Throw specific exceptions (
InvalidArgumentException
for input errors,RuntimeException
for logic errors). - Return meaningful error messages for client consumption.
- Throw specific exceptions (
-
Example Error Handling in
TaskService
:
public function createTask(array $data, int $userId): Task {
try {
// Validation and creation logic (as above)
return $this->taskRepository->create($taskData);
} catch (\PDOException $e) {
throw new \RuntimeException('Database error: ' . $e->getMessage());
}
}
-
Testing Validation:
- Create a test script
test_services.php
:
require_once __DIR__ . '/vendor/autoload.php'; use App\Services\TaskService; use App\Services\UserService; use App\Repositories\TaskRepository; use App\Repositories\UserRepository; try { $userService = new UserService(new UserRepository()); $user = $userService->createUser([ 'username' => 'alice', 'email' => 'alice@example.com', 'password' => 'secure123' ]); echo "Created User: " . $user->getUsername() . "\n"; $taskService = new TaskService(new TaskRepository()); $task = $taskService->createTask([ 'title' => 'Test Task', 'description' => 'A test task', 'due_date' => '2025-10-25' ], $user->getId()); echo "Created Task: " . $task->getTitle() . "\n"; // Test invalid input $taskService->createTask(['title' => ''], $user->getId()); // Should throw exception } catch (\Exception $e) { echo "Error: " . $e->getMessage(); }
- Create a test script
Expected Output:
Created User: alice
Created Task: Test Task
Error: Task title must be at least 3 characters long
Hands-On Activity
Let’s put the Business Logic Layer to work with practical tasks!
-
Task 1: Implement Service Classes
- Create
TaskService.php
andUserService.php
as shown. - Run the
test_services.php
script to verify user and task creation.
- Create
-
Task 2: Add a New Business Rule
- Modify
TaskService::createTask
to enforce a maximum title length of 100 characters. - Test it by attempting to create a task with a title longer than 100 characters.
- Modify
-
Task 3: Test Authorization
- Try updating a task with a different
userId
inTaskService::updateTask
. - Verify that it throws an “unauthorized” exception.
- Try updating a task with a different
-
Task 4: Extend UserService
- Add a method to
UserService
to update a user’s email, ensuring the new email is unique. - Test it with a script that updates a user’s email and retrieves the updated user.
- Add a method to
-
Task 5: Simulate a Real-World Scenario
- Write a script to:
- Create a user.
- Create two tasks for that user.
- Retrieve all tasks for the user.
- Update one task’s status to
completed
. - Delete the other task.
- Print the results at each step.
Resources
- PHP 8 Exception Handling: https://www.php.net/manual/en/language.exceptions.php
- PHP Input Validation: https://www.php.net/manual/en/filter.filters.validate.php
- Dependency Injection in PHP: https://www.php-fig.org/psr/psr-11/
- Business Logic Layer Guide: https://martinfowler.com/eaaCatalog/serviceLayer.html
- REST API Error Handling: https://restfulapi.net/http-status-codes/ # Module 8: Building the Presentation Layer (API Controllers)
Overview
Welcome to Module 8 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now at the exciting stage of building the Presentation Layer, where our Task Management API comes to life for clients. This layer, implemented as API controllers, handles incoming HTTP requests, interacts with the Business Logic Layer, and sends JSON responses back to the client. In this module, we’ll create RESTful controllers, define key endpoints for tasks and users, implement request and response handling, and ensure consistent JSON output. By the end, you’ll have a fully functional Presentation Layer that ties together our N-Tier architecture. Let’s make our API accessible and user-friendly!
Learning Objectives
- Understand the role of the Presentation Layer in N-Tier architecture.
- Create RESTful controllers to handle HTTP requests for tasks and users.
- Define and implement core API endpoints (GET, POST, PUT, DELETE).
- Implement request parsing and response formatting for consistent JSON output.
- Test the controllers using Postman to ensure they work as expected.
Content
8.1 Creating RESTful Controllers
The Presentation Layer is the entry point for client requests, responsible for routing requests to services and formatting responses. Controllers keep logic minimal, delegating to the Business Logic Layer for processing.
-
Role of Controllers:
- Parse incoming HTTP requests (e.g., JSON payloads, query parameters).
- Call appropriate service methods to process requests.
- Return JSON responses with appropriate HTTP status codes.
-
RESTful Controller Design:
- Follow REST conventions: Use HTTP methods (GET, POST, PUT, DELETE) for CRUD operations.
- Organize endpoints around resources (e.g.,
/tasks
,/users
). - Keep controllers thin, relying on services for business logic.
-
Example Structure:
-
TaskController
: Handles task-related endpoints (e.g.,GET /tasks
,POST /tasks
). -
UserController
: Manages user-related endpoints (e.g.,POST /users
for registration).
-
8.2 Defining API Endpoints (GET, POST, PUT, DELETE)
Let’s define the core endpoints for our Task Management API, mapping to CRUD operations.
-
Task Endpoints:
-
GET /tasks
: List all tasks for a user. -
GET /tasks/{id}
: Get a specific task by ID. -
POST /tasks
: Create a new task. -
PUT /tasks/{id}
: Update an existing task. -
DELETE /tasks/{id}
: Delete a task.
-
-
User Endpoints:
-
POST /users
: Create a new user (registration). -
GET /users/{id}
: Get user details by ID.
-
-
Implementation in
src/Controllers/TaskController.php
:
namespace App\Controllers;
use App\Services\TaskService;
class TaskController {
private $taskService;
public function __construct(TaskService $taskService) {
$this->taskService = $taskService;
}
public function index(int $userId) {
try {
$tasks = $this->taskService->getTasksByUser($userId);
$this->sendResponse(200, ['data' => array_map(fn($task) => $task->toArray(), $tasks)]);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function show(int $id, int $userId) {
try {
$task = $this->taskService->getTasksByUser($userId);
$task = array_filter($task, fn($t) => $t->getId() == $id);
if (empty($task)) {
$this->sendError(404, 'Task not found');
}
$this->sendResponse(200, ['data' => reset($task)->toArray()]);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function store() {
try {
$data = $this->getRequestData();
$userId = $data['user_id'] ?? 1; // Temporary; will use auth in Module 10
$task = $this->taskService->createTask($data, $userId);
$this->sendResponse(201, ['data' => $task->toArray(), 'message' => 'Task created']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function update(int $id) {
try {
$data = $this->getRequestData();
$userId = $data['user_id'] ?? 1; // Temporary; will use auth in Module 10
$this->taskService->updateTask($id, $data, $userId);
$this->sendResponse(200, ['message' => 'Task updated']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function destroy(int $id) {
try {
$userId = 1; // Temporary; will use auth in Module 10
$this->taskService->deleteTask($id, $userId);
$this->sendResponse(200, ['message' => 'Task deleted']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
private function getRequestData(): array {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
private function sendError(int $status, string $message) {
$this->sendResponse($status, ['error' => $message]);
}
}
-
Implementation in
src/Controllers/UserController.php
:
namespace App\Controllers;
use App\Services\UserService;
class UserController {
private $userService;
public function __construct(UserService $userService) {
$this->userService = $userService;
}
public function store() {
try {
$data = $this->getRequestData();
$user = $this->userService->createUser($data);
$this->sendResponse(201, ['data' => $user->toArray(), 'message' => 'User created']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function show(int $id) {
try {
$user = $this->userService->getUserById($id);
if (!$user) {
$this->sendError(404, 'User not found');
}
$this->sendResponse(200, ['data' => $user->toArray()]);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
private function getRequestData(): array {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
private function sendError(int $status, string $message) {
$this->sendResponse($status, ['error' => $message]);
}
}
-
Key Features:
- Dependency Injection: Controllers receive services via constructor.
-
HTTP Methods: Map to RESTful actions (e.g.,
store
for POST,show
for GET). - Error Handling: Catches exceptions from services and returns JSON errors.
- Temporary User ID: Hardcoded for now; we’ll add authentication in Module 10.
8.3 Implementing Request and Response Handling
Controllers must parse incoming requests and format consistent JSON responses.
-
Request Handling:
- Use
file_get_contents('php://input')
to read JSON payloads. - Parse query parameters or URL segments for dynamic routes (e.g.,
/tasks/{id}
). - Example:
POST /tasks
with JSON body{"title": "New Task", "due_date": "2025-10-25"}
.
- Use
-
Response Handling:
- Set appropriate HTTP status codes (e.g., 200 for success, 201 for creation, 404 for not found).
- Use a consistent JSON structure:
{"data": ..., "message": ...}
or{"error": ...}
. - Example Response for
POST /tasks
:
{ "data": { "id": 1, "user_id": 1, "title": "New Task", "description": null, "due_date": "2025-10-25", "status": "pending", "created_at": "2025-10-12 16:56:00", "updated_at": "2025-10-12 16:56:00" }, "message": "Task created" }
-
Helper Methods:
-
getRequestData()
parses JSON input. -
sendResponse()
andsendError()
standardize JSON output and status codes.
-
8.4 Structuring JSON Responses for Consistency
Consistent responses make the API predictable and easy to use.
-
Response Structure:
- Success:
{"data": [resource or array], "message": "Success message"}
- Error:
{"error": "Error message"}
- Success:
-
Example Responses:
-
GET /tasks
:
{ "data": [ {"id": 1, "title": "Plan meeting", "status": "pending", ...}, {"id": 2, "title": "Write report", "status": "in_progress", ...} ], "message": "Tasks retrieved" }
-
DELETE /tasks/1
:
{ "message": "Task deleted" }
- Error (e.g., invalid input):
{ "error": "Task title must be at least 3 characters long" }
-
-
Best Practices:
- Always include
Content-Type: application/json
header. - Use standard HTTP status codes (200, 201, 400, 404, etc.).
- Keep response keys consistent (
data
,message
,error
).
- Always include
8.5 Updating the Router
Update the router to handle the new controllers and endpoints.
-
Update
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php';
use App\Routing\Router;
use App\Controllers\TaskController;
use App\Controllers\UserController;
use App\Services\TaskService;
use App\Services\UserService;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
$router = new Router();
// Task Routes
$taskController = new TaskController(new TaskService(new TaskRepository()));
$router->get('/api/tasks', fn() => $taskController->index(1)); // Temporary user ID
$router->get('/api/tasks/(\d+)', fn($id) => $taskController->show($id, 1));
$router->post('/api/tasks', fn() => $taskController->store());
$router->put('/api/tasks/(\d+)', fn($id) => $taskController->update($id));
$router->delete('/api/tasks/(\d+)', fn($id) => $taskController->destroy($id));
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/api/users', fn() => $userController->store());
$router->get('/api/users/(\d+)', fn($id) => $userController->show($id));
$router->dispatch();
-
Note: The router now supports dynamic routes (e.g.,
/tasks/(\d+)
for IDs). We’ll enhance it further in Module 9.
Hands-On Activity
Let’s bring the Presentation Layer to life with practical tasks!
-
Task 1: Implement Controllers
- Create
TaskController.php
andUserController.php
as shown. - Update
index.php
with the new routes.
- Create
-
Task 2: Test Endpoints with Postman
- Create a Postman collection for the API.
- Test the following:
-
POST /api/users
with{"username": "bob", "email": "bob@example.com", "password": "secure123"}
. -
GET /api/users/1
to retrieve the user. -
POST /api/tasks
with{"user_id": 1, "title": "Test Task", "due_date": "2025-10-25"}
. -
GET /api/tasks
to list tasks. -
PUT /api/tasks/1
with{"title": "Updated Task", "status": "completed"}
. -
DELETE /api/tasks/1
to delete the task. - Verify responses match the expected JSON structure.
-
Task 3: Add a New Endpoint
- Add a
GET /api/tasks/status/{status}
endpoint toTaskController
to filter tasks by status. - Update
TaskService
andTaskRepository
to support this (e.g., addfindByStatus
method). - Test it with
GET /api/tasks/status/pending
in Postman.
- Add a
-
Task 4: Simulate Error Cases
- Test error responses by sending invalid data (e.g.,
POST /api/tasks
with{"title": ""}
). - Verify the response contains
{"error": "Task title must be at least 3 characters long"}
and status 400.
- Test error responses by sending invalid data (e.g.,
-
Task 5: Update README
- Update
README.md
with the new endpoints and example requests/responses. - Include instructions for testing with Postman.
- Update
Resources
- RESTful API Design: https://restfulapi.net/
- PHP HTTP Status Codes: https://www.php.net/manual/en/function.http-response-code.php
- JSON Handling in PHP: https://www.php.net/manual/en/book.json.php
- Postman Documentation: https://learning.postman.com/docs/getting-started/introduction/
- Controller Design Patterns: https://martinfowler.com/eaaCatalog/frontController.html # Module 9: Routing and Request Handling
Overview
Welcome to Module 9 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now diving into the critical mechanics of routing and request handling, the backbone of our Task Management API’s ability to process client requests efficiently. In this module, we’ll enhance our routing system to support dynamic routes and query parameters, implement middleware for preprocessing requests (like authentication and logging), and ensure robust parsing of input data (JSON, form-data, etc.). By the end, you’ll have a powerful routing system that seamlessly directs requests to the right controllers and prepares our API for advanced features. Let’s get those requests flowing smoothly!
Learning Objectives
- Implement an advanced routing system to handle static and dynamic routes.
- Support query parameters for flexible API queries.
- Create middleware for preprocessing requests (e.g., authentication, logging).
- Parse various input formats (JSON, form-data) securely and reliably.
- Test the routing system with Postman to ensure correct request handling.
Content
9.1 Implementing a Custom Router for RESTful Endpoints
Our basic router from Module 4 needs an upgrade to handle dynamic routes (e.g., /tasks/{id}
) and provide better flexibility. Let’s create a more robust version.
-
Enhance
src/Routing/Router.php
:
namespace App\Routing;
class Router {
private $routes = [];
public function get(string $path, callable $callback) {
$this->routes['GET'][$path] = $callback;
}
public function post(string $path, callable $callback) {
$this->routes['POST'][$path] = $callback;
}
public function put(string $path, callable $callback) {
$this->routes['PUT'][$path] = $callback;
}
public function delete(string $path, callable $callback) {
$this->routes['DELETE'][$path] = $callback;
}
public function dispatch() {
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Remove base path if API is not at root
$basePath = '/api';
if (strpos($uri, $basePath) === 0) {
$uri = substr($uri, strlen($basePath));
}
foreach ($this->routes[$method] ?? [] as $route => $callback) {
// Convert route to regex pattern
$pattern = preg_replace('#\{([\w]+)\}#', '([^/]+)', $route);
$pattern = "#^$pattern$#";
if (preg_match($pattern, $uri, $matches)) {
array_shift($matches); // Remove full match
return call_user_func_array($callback, $matches);
}
}
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'Route not found']);
}
}
-
Key Improvements:
- Supports all HTTP methods (GET, POST, PUT, DELETE).
- Handles dynamic routes using regex (e.g.,
/tasks/{id}
matches/tasks/1
). - Strips base path (
/api
) for cleaner route matching.
-
Update
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php';
use App\Routing\Router;
use App\Controllers\TaskController;
use App\Controllers\UserController;
use App\Services\TaskService;
use App\Services\UserService;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
$router = new Router();
// Task Routes
$taskController = new TaskController(new TaskService(new TaskRepository()));
$router->get('/tasks', fn() => $taskController->index(1)); // Temporary user ID
$router->get('/tasks/(\d+)', fn($id) => $taskController->show($id, 1));
$router->post('/tasks', fn() => $taskController->store());
$router->put('/tasks/(\d+)', fn($id) => $taskController->update($id));
$router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id));
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/users', fn() => $userController->store());
$router->get('/users/(\d+)', fn($id) => $userController->show($id));
$router->dispatch();
-
Why This Router?
- Lightweight and framework-free, perfect for learning.
- Flexible enough to handle dynamic routes and future middleware.
9.2 Handling Dynamic Routes and Query Parameters
Dynamic routes and query parameters allow clients to access specific resources or filter results.
-
Dynamic Routes:
- Example:
/tasks/(\d+)
matches/tasks/1
and passes1
as$id
to the controller. - Handled by the regex in
Router::dispatch()
.
- Example:
-
Query Parameters:
- Example:
GET /tasks?status=pending&limit=10
filters tasks by status and limits results. - Update
TaskController::index
to handle query parameters:
public function index(int $userId) { try { $status = $_GET['status'] ?? null; $limit = (int)($_GET['limit'] ?? 10); $tasks = $this->taskService->getTasksByUser($userId, $status, $limit); $this->sendResponse(200, ['data' => array_map(fn($task) => $task->toArray(), $tasks)]); } catch (\Exception $e) { $this->sendError(400, $e->getMessage()); } }
- Example:
Update
TaskService
to Support Query Parameters:
public function getTasksByUser(int $userId, ?string $status = null, int $limit = 10): array {
$tasks = $this->taskRepository->findByUserId($userId);
if ($status) {
$tasks = array_filter($tasks, fn($task) => $task->getStatus() === $status);
}
return array_slice($tasks, 0, $limit);
}
-
Example Request:
-
GET /api/tasks?status=pending&limit=5
returns up to 5 pending tasks for the user.
-
9.3 Middleware for Request Preprocessing
Middleware processes requests before they reach controllers, ideal for tasks like authentication or logging.
-
Create
src/Middleware/RequestLogger.php
:
namespace App\Middleware;
class RequestLogger {
public function handle(callable $next) {
$log = sprintf(
"[%s] %s %s\n",
date('Y-m-d H:i:s'),
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI']
);
file_put_contents(__DIR__ . '/../../logs/requests.log', $log, FILE_APPEND);
return $next();
}
}
-
Create
src/Middleware/ApiKeyMiddleware.php
:
namespace App\Middleware;
class ApiKeyMiddleware {
public function handle(callable $next) {
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
if ($apiKey !== ($_ENV['API_KEY'] ?? 'your-secret-key')) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid API key']);
return;
}
return $next();
}
}
-
Update
Router.php
to Support Middleware:
namespace App\Routing;
class Router {
private $routes = [];
private $middleware = [];
public function get(string $path, callable $callback, array $middleware = []) {
$this->routes['GET'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function post(string $path, callable $callback, array $middleware = []) {
$this->routes['POST'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function put(string $path, callable $callback, array $middleware = []) {
$this->routes['PUT'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function delete(string $path, callable $callback, array $middleware = []) {
$this->routes['DELETE'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function dispatch() {
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$basePath = '/api';
if (strpos($uri, $basePath) === 0) {
$uri = substr($uri, strlen($basePath));
}
foreach ($this->routes[$method] ?? [] as $route => $handler) {
$pattern = preg_replace('#\{([\w]+)\}#', '([^/]+)', $route);
$pattern = "#^$pattern$#";
if (preg_match($pattern, $uri, $matches)) {
array_shift($matches);
$callback = $handler['callback'];
$middleware = $handler['middleware'];
// Run middleware
$next = fn() => call_user_func_array($callback, $matches);
foreach (array_reverse($middleware) as $mwClass) {
$mw = new $mwClass();
$next = fn() => $mw->handle($next);
}
return $next();
}
}
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'Route not found']);
}
}
-
Update
index.php
to Use Middleware:
use App\Middleware\RequestLogger;
use App\Middleware\ApiKeyMiddleware;
$router = new Router();
// Task Routes with Middleware
$taskController = new TaskController(new TaskService(new TaskRepository()));
$router->get('/tasks', fn() => $taskController->index(1), [RequestLogger::class, ApiKeyMiddleware::class]);
$router->get('/tasks/(\d+)', fn($id) => $taskController->show($id, 1), [RequestLogger::class, ApiKeyMiddleware::class]);
$router->post('/tasks', fn() => $taskController->store(), [RequestLogger::class, ApiKeyMiddleware::class]);
$router->put('/tasks/(\d+)', fn($id) => $taskController->update($id), [RequestLogger::class, ApiKeyMiddleware::class]);
$router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id), [RequestLogger::class, ApiKeyMiddleware::class]);
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/users', fn() => $userController->store(), [RequestLogger::class]);
$router->get('/users/(\d+)', fn($id) => $userController->show($id), [RequestLogger::class]);
$router->dispatch();
-
Why Middleware?
-
RequestLogger
: Logs every request for debugging and monitoring. -
ApiKeyMiddleware
: Adds basic API key authentication (enhanced in Module 10). - Middleware runs before controllers, allowing early request validation.
-
9.4 Parsing Input Data (JSON, Form-Data)
Controllers need to handle various input formats securely.
-
Update
TaskController::getRequestData
:
private function getRequestData(): array {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (strpos($contentType, 'application/json') !== false) {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
} elseif (strpos($contentType, 'multipart/form-data') !== false) {
return $_POST;
}
return [];
}
-
Update
UserController::getRequestData
:- Copy the same method from
TaskController
.
- Copy the same method from
-
Why This Approach?
- Handles JSON (common for APIs) and form-data (useful for file uploads in future modules).
- Fallback to empty array prevents errors on invalid input.
-
Security Considerations:
- Validate
CONTENT_TYPE
header to avoid processing unexpected formats. - Sanitize inputs in services (already done in Module 7).
- Validate
Hands-On Activity
Let’s test and enhance our routing system!
-
Task 1: Implement the Enhanced Router
- Update
Router.php
andindex.php
with the new code. - Test all endpoints (
GET /api/tasks
,POST /api/tasks
, etc.) in Postman with theX-API-KEY
header set toyour-secret-key
.
- Update
-
Task 2: Test Query Parameters
- Send
GET /api/tasks?status=pending&limit=2
in Postman. - Verify the response contains up to 2 pending tasks.
- Try an invalid status (e.g.,
status=invalid
) and check for an error.
- Send
-
Task 3: Create a New Middleware
- Create a
RateLimitMiddleware
insrc/Middleware/RateLimitMiddleware.php
that limits requests to 100 per minute per IP (use a simple array or file-based counter). - Apply it to the
POST /api/tasks
route and test by sending multiple requests.
- Create a
-
Task 4: Test Input Parsing
- Send a
POST /api/tasks
request with form-data (title=Test Task&due_date=2025-10-25
) instead of JSON. - Verify the task is created correctly.
- Send a
-
Task 5: Update README
- Add a section to
README.md
describing the new routing features, middleware, and supported query parameters. - Include an example request with the
X-API-KEY
header.
- Add a section to
Resources
- PHP Regular Expressions: https://www.php.net/manual/en/book.pcre.php
- HTTP Request Parsing: https://www.php.net/manual/en/reserved.variables.server.php
- Middleware Pattern: https://martinfowler.com/articles/injection.html#Middleware
- Postman Documentation: https://learning.postman.com/docs/getting-started/introduction/
- RESTful Routing Best Practices: https://restfulapi.net/resource-naming/ # Module 10: Authentication and Authorization
Overview
Welcome to Module 10 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now at a critical stage: securing our Task Management API with authentication and authorization. This module will guide you through implementing a robust authentication system using JSON Web Tokens (JWT), securing endpoints to ensure only authorized users can access or modify resources, and handling token generation, validation, and refresh. By the end, your API will be locked down, ensuring only authenticated users can interact with it, and you’ll have a solid foundation for user-specific access control. Let’s make our API secure and user-friendly!
Learning Objectives
- Understand API authentication methods and why JWT is a great fit.
- Implement JWT-based authentication for user login and token generation.
- Secure API endpoints with role-based access control using middleware.
- Handle token validation and refresh for a seamless user experience.
- Test authentication and authorization flows using Postman.
Content
10.1 Overview of API Authentication Methods
Authentication verifies a user’s identity, while authorization determines what they can do. For APIs, stateless authentication is key, and JWT is a popular choice.
-
Common Authentication Methods:
- API Keys: Simple but limited for user-specific access (used in Module 9).
- OAuth 2.0: Powerful for third-party access but complex for our needs.
- JWT (JSON Web Tokens): Compact, self-contained tokens for stateless authentication.
-
Why JWT?
- Encodes user data (e.g., user ID) in a signed token.
- Stateless: No server-side session storage needed.
- Secure: Signed with a secret key to prevent tampering.
- Flexible: Supports expiration and custom claims.
-
JWT Structure:
- Header: Defines the token type and signing algorithm (e.g., HS256).
-
Payload: Contains claims like user ID, expiration time (
exp
), and issued time (iat
). - Signature: Ensures token integrity using a secret key.
- Example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzEzNzE5NjB9.signature
10.2 Implementing JWT-Based Authentication
We’ll use the firebase/php-jwt
library to handle JWT generation and validation. Users will log in via a POST /auth/login
endpoint to receive a token, which they’ll include in subsequent requests.
- Install the JWT Library:
composer require firebase/php-jwt
-
Update
.env
with a Secret Key:
JWT_SECRET=your-secure-jwt-secret
JWT_EXPIRY=3600 # Token expires in 1 hour
-
Create
src/Services/AuthService.php
:
namespace App\Services;
use App\Repositories\UserRepository;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthService {
private $userRepository;
public function __construct(UserRepository $userRepository) {
$this->userRepository = $userRepository;
}
public function login(string $email, string $password): array {
$user = $this->userRepository->findByEmail($email);
if (!$user || !password_verify($password, $user->getPassword())) {
throw new \RuntimeException('Invalid email or password');
}
$payload = [
'iat' => time(),
'exp' => time() + (int)$_ENV['JWT_EXPIRY'],
'user_id' => $user->getId(),
'username' => $user->getUsername()
];
$token = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
return [
'token' => $token,
'user' => $user->toArray()
];
}
public function validateToken(string $token): array {
try {
$decoded = JWT::decode($token, new Key($_ENV['JWT_SECRET'], 'HS256'));
return (array)$decoded;
} catch (\Exception $e) {
throw new \RuntimeException('Invalid or expired token');
}
}
}
-
Explanation:
-
login
: Verifies user credentials and generates a JWT with user ID and expiration. -
validateToken
: Decodes and validates the JWT, returning the payload. - Uses
password_verify
to securely check passwords (set inUserRepository
).
-
10.3 Securing Endpoints with Role-Based Access Control
We’ll create a middleware to check JWTs and ensure users can only access their own resources.
-
Create
src/Middleware/AuthMiddleware.php
:
namespace App\Middleware;
use App\Services\AuthService;
class AuthMiddleware {
private $authService;
public function __construct(AuthService $authService) {
$this->authService = $authService;
}
public function handle(callable $next) {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Missing or invalid Authorization header']);
return;
}
try {
$token = $matches[1];
$payload = $this->authService->validateToken($token);
$_SERVER['USER_ID'] = $payload['user_id']; // Store user ID for controllers
return $next();
} catch (\Exception $e) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage()]);
return;
}
}
}
-
Update
TaskController
to Use Authenticated User ID:
namespace App\Controllers;
use App\Services\TaskService;
class TaskController {
private $taskService;
public function __construct(TaskService $taskService) {
$this->taskService = $taskService;
}
public function index() {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$tasks = $this->taskService->getTasksByUser($userId, $status, $limit);
$this->sendResponse(200, ['data' => array_map(fn($task) => $task->toArray(), $tasks)]);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function show(int $id) {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$task = $this->taskService->getTasksByUser($userId);
$task = array_filter($task, fn($t) => $t->getId() == $id);
if (empty($task)) {
$this->sendError(404, 'Task not found');
}
$this->sendResponse(200, ['data' => reset($task)->toArray()]);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function store() {
try {
$data = $this->getRequestData();
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$data['user_id'] = $userId;
$task = $this->taskService->createTask($data, $userId);
$this->sendResponse(201, ['data' => $task->toArray(), 'message' => 'Task created']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function update(int $id) {
try {
$data = $this->getRequestData();
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$this->taskService->updateTask($id, $data, $userId);
$this->sendResponse(200, ['message' => 'Task updated']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
public function destroy(int $id) {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$this->taskService->deleteTask($id, $userId);
$this->sendResponse(200, ['message' => 'Task deleted']);
} catch (\Exception $e) {
$this->sendError(400, $e->getMessage());
}
}
private function getRequestData(): array {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (strpos($contentType, 'application/json') !== false) {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
} elseif (strpos($contentType, 'multipart/form-data') !== false) {
return $_POST;
}
return [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
private function sendError(int $status, string $message) {
$this->sendResponse($status, ['error' => $message]);
}
}
-
Create
src/Controllers/AuthController.php
:
namespace App\Controllers;
use App\Services\AuthService;
class AuthController {
private $authService;
public function __construct(AuthService $authService) {
$this->authService = $authService;
}
public function login() {
try {
$data = $this->getRequestData();
$email = $data['email'] ?? throw new \InvalidArgumentException('Email is required');
$password = $data['password'] ?? throw new \InvalidArgumentException('Password is required');
$result = $this->authService->login($email, $password);
$this->sendResponse(200, ['data' => $result, 'message' => 'Login successful']);
} catch (\Exception $e) {
$this->sendError(401, $e->getMessage());
}
}
private function getRequestData(): array {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
private function sendError(int $status, string $message) {
$this->sendResponse($status, ['error' => $message]);
}
}
-
Update
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php';
use App\Routing\Router;
use App\Controllers\TaskController;
use App\Controllers\UserController;
use App\Controllers\AuthController;
use App\Services\TaskService;
use App\Services\UserService;
use App\Services\AuthService;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
use App\Middleware\RequestLogger;
use App\Middleware\AuthMiddleware;
$router = new Router();
// Auth Routes
$authController = new AuthController(new AuthService(new UserRepository()));
$router->post('/auth/login', fn() => $authController->login(), [RequestLogger::class]);
// Task Routes
$taskController = new TaskController(new TaskService(new TaskRepository()));
$router->get('/tasks', fn() => $taskController->index(), [RequestLogger::class, AuthMiddleware::class]);
$router->get('/tasks/(\d+)', fn($id) => $taskController->show($id), [RequestLogger::class, AuthMiddleware::class]);
$router->post('/tasks', fn() => $taskController->store(), [RequestLogger::class, AuthMiddleware::class]);
$router->put('/tasks/(\d+)', fn($id) => $taskController->update($id), [RequestLogger::class, AuthMiddleware::class]);
$router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id), [RequestLogger::class, AuthMiddleware::class]);
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/users', fn() => $userController->store(), [RequestLogger::class]);
$router->get('/users/(\d+)', fn($id) => $userController->show($id), [RequestLogger::class, AuthMiddleware::class]);
$router->dispatch();
-
Key Features:
-
AuthMiddleware
checks for a validAuthorization: Bearer <token>
header. - Stores authenticated user ID in
$_SERVER['USER_ID']
for controllers. -
AuthController
handles login and returns a JWT.
-
10.4 Handling Token Generation, Validation, and Refresh
To support long-lived sessions, we’ll implement a token refresh mechanism.
-
Add Refresh Token Method to
AuthService
:
public function refreshToken(string $token): array {
try {
$payload = $this->validateToken($token);
$newPayload = [
'iat' => time(),
'exp' => time() + (int)$_ENV['JWT_EXPIRY'],
'user_id' => $payload['user_id'],
'username' => $payload['username']
];
$newToken = JWT::encode($newPayload, $_ENV['JWT_SECRET'], 'HS256');
return ['token' => $newToken];
} catch (\Exception $e) {
throw new \RuntimeException('Invalid or expired token');
}
}
-
Add Refresh Endpoint to
AuthController
:
public function refresh() {
try {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
throw new \InvalidArgumentException('Missing Authorization header');
}
$token = $matches[1];
$result = $this->authService->refreshToken($token);
$this->sendResponse(200, ['data' => $result, 'message' => 'Token refreshed']);
} catch (\Exception $e) {
$this->sendError(401, $e->getMessage());
}
}
-
Update
index.php
:
$router->post('/auth/refresh', fn() => $authController->refresh(), [RequestLogger::class]);
-
How It Works:
- Clients send a valid token to
POST /auth/refresh
to get a new token with extended expiration. - Ensures users remain authenticated without needing to re-login.
- Clients send a valid token to
Hands-On Activity
Let’s secure and test our API’s authentication system!
-
Task 1: Set Up JWT Authentication
- Install
firebase/php-jwt
and update.env
withJWT_SECRET
. - Implement
AuthService.php
,AuthController.php
, andAuthMiddleware.php
.
- Install
-
Task 2: Test Login and Protected Endpoints
- Create a user via
POST /api/users
with{"username": "testuser", "email": "test@example.com", "password": "secure123"}
. - Send
POST /api/auth/login
with{"email": "test@example.com", "password": "secure123"}
in Postman. - Copy the returned JWT and use it in the
Authorization: Bearer <token>
header forGET /api/tasks
. - Verify access and try with an invalid token to see a 401 error.
- Create a user via
-
Task 3: Test Token Refresh
- Send
POST /api/auth/refresh
with a valid token in theAuthorization
header. - Verify a new token is returned with an updated
exp
claim.
- Send
-
Task 4: Add Role-Based Authorization
- Modify
User.php
to add arole
field (e.g.,admin
oruser
). - Update
UserRepository
andUserService
to handle roles. - Add a check in
AuthMiddleware
to allow onlyadmin
users to accessGET /api/users/{id}
. - Test with a non-admin user to verify a 403 error.
- Modify
-
Task 5: Update README
- Add a section to
README.md
describing the authentication flow, including login, token usage, and refresh. - Include example requests for
POST /api/auth/login
andPOST /api/auth/refresh
.
- Add a section to
Resources
- JWT Introduction: https://jwt.io/introduction/
- PHP-JWT Library: https://github.com/firebase/php-jwt
- Password Hashing in PHP: https://www.php.net/manual/en/function.password-hash.php
- REST API Security: https://restfulapi.net/security-essentials/
- Postman Authentication Guide: https://learning.postman.com/docs/sending-requests/authorization/ # Module 11: Input Validation and Sanitization
Overview
Welcome to Module 11 of Build a Robust RESTful API with PHP 8, from Scratch! We're now focusing on input validation and sanitization, critical components for ensuring our Task Management API is secure, reliable, and user-friendly. In this module, we’ll enhance our API by implementing robust validation for incoming requests, leveraging PHP 8’s attributes for declarative validation, creating custom validation rules, and sanitizing inputs to prevent security vulnerabilities like SQL injection or XSS. By the end, your API will gracefully handle invalid inputs and maintain high security standards. Let’s dive in and make our API bulletproof!
Learning Objectives
- Understand the importance of input validation and sanitization in APIs.
- Implement validation for incoming API requests in the Business Logic Layer.
- Use PHP 8 attributes to define declarative validation rules.
- Create custom validation rules for specific API requirements.
- Sanitize inputs to prevent common security vulnerabilities.
Content
11.1 Validating Incoming API Requests
Input validation ensures that client requests meet the API’s expectations before processing, preventing errors and security issues.
-
Why Validate?
- Protects against invalid or malicious data (e.g., empty fields, malformed emails).
- Improves user experience with clear error messages.
- Reduces server load by catching errors early.
-
Where to Validate?
- Primary validation in the Business Logic Layer (
TaskService
,UserService
) to keep controllers thin. - Additional checks in controllers for request-specific requirements (e.g., required fields).
- Primary validation in the Business Logic Layer (
-
Validation Strategy:
- Check for required fields, data types, and valid formats.
- Use PHP’s built-in functions like
filter_var()
and custom rules. - Return meaningful error messages with HTTP 400 status codes.
-
Example (Update
TaskService
):
public function createTask(array $data, int $userId): Task {
$errors = [];
if (empty($data['title'])) {
$errors[] = 'Title is required';
} elseif (strlen($data['title']) < 3 || strlen($data['title']) > 100) {
$errors[] = 'Title must be between 3 and 100 characters';
}
if (isset($data['due_date']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['due_date'])) {
$errors[] = 'Due date must be in YYYY-MM-DD format';
}
if (isset($data['status']) && !in_array($data['status'], ['pending', 'in_progress', 'completed'])) {
$errors[] = 'Invalid task status';
}
if ($errors) {
throw new \InvalidArgumentException(implode(', ', $errors));
}
$taskData = [
'user_id' => $userId,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
];
return $this->taskRepository->create($taskData);
}
-
Key Points:
- Collects all validation errors for a single response.
- Validates title length, due date format, and status values.
- Throws an exception with a concatenated error message.
11.2 Using PHP 8 Attributes for Validation
PHP 8’s attributes allow us to define validation rules declaratively, making code cleaner and more maintainable.
-
Create
src/Validation/Attributes/Validate.php
:
namespace App\Validation\Attributes;
#[\Attribute]
class Validate {
public function __construct(
public bool $required = false,
public ?int $minLength = null,
public ?int $maxLength = null,
public ?string $pattern = null,
public ?array $allowedValues = null
) {}
}
-
Create a Validation Helper
src/Validation/Validator.php
:
namespace App\Validation;
use App\Validation\Attributes\Validate;
use ReflectionClass;
class Validator {
public static function validate(object $dto): array {
$errors = [];
$reflection = new ReflectionClass($dto);
foreach ($reflection->getProperties() as $property) {
$attributes = $property->getAttributes(Validate::class);
if ($attributes) {
$validate = $attributes[0]->newInstance();
$value = $property->getValue($dto);
$name = $property->getName();
if ($validate->required && (is_null($value) || $value === '')) {
$errors[] = "$name is required";
}
if ($validate->minLength && strlen($value) < $validate->minLength) {
$errors[] = "$name must be at least {$validate->minLength} characters";
}
if ($validate->maxLength && strlen($value) > $validate->maxLength) {
$errors[] = "$name must not exceed {$validate->maxLength} characters";
}
if ($validate->pattern && !preg_match($validate->pattern, $value)) {
$errors[] = "$name format is invalid";
}
if ($validate->allowedValues && !in_array($value, $validate->allowedValues)) {
$errors[] = "$name must be one of: " . implode(', ', $validate->allowedValues);
}
}
}
return $errors;
}
}
-
Create a Data Transfer Object (DTO) for Tasks
src/DTO/TaskDTO.php
:
namespace App\DTO;
use App\Validation\Attributes\Validate;
class TaskDTO {
#[Validate(required: true, minLength: 3, maxLength: 100)]
public string $title;
#[Validate]
public ?string $description = null;
#[Validate(pattern: '/^\d{4}-\d{2}-\d{2}$/')]
public ?string $dueDate = null;
#[Validate(allowedValues: ['pending', 'in_progress', 'completed'])]
public ?string $status = 'pending';
public function __construct(array $data) {
$this->title = $data['title'] ?? '';
$this->description = $data['description'] ?? null;
$this->dueDate = $data['due_date'] ?? null;
$this->status = $data['status'] ?? 'pending';
}
public function toArray(): array {
return [
'title' => $this->title,
'description' => $this->description,
'due_date' => $this->dueDate,
'status' => $this->status
];
}
}
-
Update
TaskService::createTask
:
public function createTask(array $data, int $userId): Task {
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new \InvalidArgumentException(implode(', ', $errors));
}
$taskData = array_merge($dto->toArray(), ['user_id' => $userId]);
return $this->taskRepository->create($taskData);
}
-
Why Attributes?
- Declarative validation reduces boilerplate code in services.
- Centralizes rules in DTOs, making them reusable and maintainable.
- Leverages PHP 8’s modern features for cleaner code.
11.3 Implementing Custom Validation Rules
Custom validation rules handle specific requirements not covered by standard checks.
-
Create
src/Validation/Rules/DueDateNotPast.php
:
namespace App\Validation\Rules;
use App\Validation\Attributes\Validate;
#[\Attribute]
class DueDateNotPast extends Validate {
public function validate($value, string $name): ?string {
if ($value && strtotime($value) < time()) {
return "$name cannot be in the past";
}
return null;
}
}
-
Update
Validator.php
to Handle Custom Rules:
public static function validate(object $dto): array {
$errors = [];
$reflection = new ReflectionClass($dto);
foreach ($reflection->getProperties() as $property) {
$attributes = $property->getAttributes();
$value = $property->getValue($dto);
$name = $property->getName();
foreach ($attributes as $attribute) {
$attrInstance = $attribute->newInstance();
if ($attrInstance instanceof Validate) {
if ($attrInstance->required && (is_null($value) || $value === '')) {
$errors[] = "$name is required";
}
if ($attrInstance->minLength && strlen($value) < $attrInstance->minLength) {
$errors[] = "$name must be at least {$attrInstance->minLength} characters";
}
if ($attrInstance->maxLength && strlen($value) > $attrInstance->maxLength) {
$errors[] = "$name must not exceed {$attrInstance->maxLength} characters";
}
if ($attrInstance->pattern && !preg_match($attrInstance->pattern, $value)) {
$errors[] = "$name format is invalid";
}
if ($attrInstance->allowedValues && !in_array($value, $attrInstance->allowedValues)) {
$errors[] = "$name must be one of: " . implode(', ', $attrInstance->allowedValues);
}
} elseif (method_exists($attrInstance, 'validate')) {
$error = $attrInstance->validate($value, $name);
if ($error) {
$errors[] = $error;
}
}
}
}
return $errors;
}
-
Update
TaskDTO.php
:
use App\Validation\Rules\DueDateNotPast;
class TaskDTO {
#[Validate(required: true, minLength: 3, maxLength: 100)]
public string $title;
#[Validate]
public ?string $description = null;
#[Validate(pattern: '/^\d{4}-\d{2}-\d{2}$/')]
#[DueDateNotPast]
public ?string $dueDate = null;
#[Validate(allowedValues: ['pending', 'in_progress', 'completed'])]
public ?string $status = 'pending';
// ... constructor and toArray unchanged
}
-
Why Custom Rules?
- Handle specific logic (e.g., due date not in the past).
- Extensible for future requirements (e.g., validate priority levels).
11.4 Sanitizing Inputs to Prevent Security Vulnerabilities
Sanitization removes or escapes harmful data to prevent vulnerabilities like SQL injection or XSS.
-
Sanitization in
TaskService
:
public function createTask(array $data, int $userId): Task {
// Sanitize inputs
$data['title'] = filter_var($data['title'] ?? '', FILTER_SANITIZE_STRING);
$data['description'] = filter_var($data['description'] ?? '', FILTER_SANITIZE_STRING);
$data['due_date'] = filter_var($data['due_date'] ?? '', FILTER_SANITIZE_STRING);
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new \InvalidArgumentException(implode(', ', $errors));
}
$taskData = array_merge($dto->toArray(), ['user_id' => $userId]);
return $this->taskRepository->create($taskData);
}
-
Update
UserService
:
public function createUser(array $data): User {
// Sanitize inputs
$data['username'] = filter_var($data['username'] ?? '', FILTER_SANITIZE_STRING);
$data['email'] = filter_var($data['email'] ?? '', FILTER_SANITIZE_EMAIL);
// Validate input
if (empty($data['username']) || strlen($data['username']) < 3) {
throw new \InvalidArgumentException('Username must be at least 3 characters long');
}
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address');
}
if (empty($data['password']) || strlen($data['password']) < 6) {
throw new \InvalidArgumentException('Password must be at least 6 characters long');
}
if ($this->userRepository->findByEmail($data['email'])) {
throw new \RuntimeException('Email already exists');
}
return $this->userRepository->create($data);
}
-
Security Practices:
- Use
filter_var
withFILTER_SANITIZE_STRING
andFILTER_SANITIZE_EMAIL
to strip harmful characters. - Rely on PDO’s prepared statements (already in
TaskRepository
andUserRepository
) to prevent SQL injection. - Avoid XSS by sanitizing outputs in controllers if rendering HTML (not applicable here, but good practice for future).
- Use
Hands-On Activity
Let’s strengthen our API with validation and sanitization!
-
Task 1: Implement Validation with Attributes
- Create
Validate.php
,Validator.php
, andTaskDTO.php
as shown. - Update
TaskService::createTask
to use the DTO and validator. - Test
POST /api/tasks
with invalid data (e.g.,{"title": "a", "due_date": "2025-10-01"}
) and verify error messages.
- Create
-
Task 2: Add Custom Validation Rule
- Implement
DueDateNotPast.php
and updateValidator.php
andTaskDTO.php
. - Test
POST /api/tasks
with a past due date (e.g.,2025-10-10
) and verify the error.
- Implement
-
Task 3: Sanitize Inputs
- Update
TaskService
andUserService
to include sanitization. - Test by sending malicious input (e.g.,
{"title": "<script>alert('hack')</script>"}
) and verify it’s sanitized.
- Update
-
Task 4: Create a UserDTO
- Create
src/DTO/UserDTO.php
with validation attributes forusername
,email
, andpassword
. - Update
UserService::createUser
to use it. - Test
POST /api/users
with invalid data (e.g., short username, invalid email).
- Create
-
Task 5: Update README
- Add a section to
README.md
describing the validation and sanitization process. - Include example error responses for invalid inputs.
- Add a section to
Resources
- PHP 8 Attributes: https://www.php.net/manual/en/language.attributes.php
- PHP Input Filtering: https://www.php.net/manual/en/book.filter.php
- Validation Best Practices: https://owasp.org/www-community/OWASP_Validation
- PDO Prepared Statements: https://www.php.net/manual/en/pdo.prepared-statements.php
- Postman Testing: https://learning.postman.com/docs/sending-requests/requests/ # Module 12: Error Handling and Logging
Overview
Welcome to Module 12 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now focusing on error handling and logging, essential for making our Task Management API reliable, debuggable, and user-friendly. In this module, we’ll design a robust error-handling system, create custom exception classes for specific error scenarios, implement centralized logging to track issues, and ensure meaningful error responses are returned to clients. By the end, your API will handle errors gracefully and provide clear feedback, making it easier to debug and maintain. Let’s dive in and make our API resilient!
Learning Objectives
- Understand the importance of structured error handling in APIs.
- Design a centralized error-handling system for consistent responses.
- Create custom exception classes for specific API error cases.
- Implement centralized logging to track errors and API activity.
- Ensure meaningful JSON error responses for clients.
Content
12.1 Designing a Robust Error-Handling System
A robust error-handling system catches errors early, provides clear feedback to clients, and simplifies debugging for developers.
-
Goals of Error Handling:
- Return consistent JSON error responses with appropriate HTTP status codes.
- Catch and handle all types of errors (validation, database, authentication, etc.).
- Log errors for debugging without exposing sensitive details to clients.
-
Strategy:
- Use PHP’s exception handling (
try-catch
) in controllers and services. - Centralize error handling in a dedicated class to standardize responses.
- Map exceptions to HTTP status codes (e.g.,
InvalidArgumentException
→ 400,RuntimeException
→ 500).
- Use PHP’s exception handling (
-
Create
src/Exceptions/ErrorHandler.php
:
namespace App\Exceptions;
use Throwable;
class ErrorHandler {
public static function handle(Throwable $exception): void {
$statusCode = 500;
$errorMessage = 'Internal Server Error';
switch (get_class($exception)) {
case \InvalidArgumentException::class:
$statusCode = 400;
$errorMessage = $exception->getMessage();
break;
case \RuntimeException::class:
$statusCode = 401; // For auth errors
$errorMessage = $exception->getMessage();
break;
case \App\Exceptions\NotFoundException::class:
$statusCode = 404;
$errorMessage = $exception->getMessage();
break;
}
// Log the error
self::logError($exception);
// Send JSON response
http_response_code($statusCode);
header('Content-Type: application/json');
echo json_encode(['error' => $errorMessage]);
exit;
}
private static function logError(Throwable $exception): void {
$logMessage = sprintf(
"[%s] %s: %s in %s:%d\nStack trace: %s\n",
date('Y-m-d H:i:s'),
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
$exception->getTraceAsString()
);
file_put_contents(__DIR__ . '/../../logs/errors.log', $logMessage, FILE_APPEND);
}
}
-
Update
TaskController.php
to Use ErrorHandler:
namespace App\Controllers;
use App\Exceptions\ErrorHandler;
use App\Services\TaskService;
class TaskController {
private $taskService;
public function __construct(TaskService $taskService) {
$this->taskService = $taskService;
}
public function index() {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$tasks = $this->taskService->getTasksByUser($userId, $status, $limit);
$this->sendResponse(200, ['data' => array_map(fn($task) => $task->toArray(), $tasks)]);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
public function show(int $id) {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$task = $this->taskService->getTasksByUser($userId);
$task = array_filter($task, fn($t) => $t->getId() == $id);
if (empty($task)) {
throw new \App\Exceptions\NotFoundException('Task not found');
}
$this->sendResponse(200, ['data' => reset($task)->toArray()]);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
public function store() {
try {
$data = $this->getRequestData();
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$data['user_id'] = $userId;
$task = $this->taskService->createTask($data, $userId);
$this->sendResponse(201, ['data' => $task->toArray(), 'message' => 'Task created']);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
public function update(int $id) {
try {
$data = $this->getRequestData();
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$this->taskService->updateTask($id, $data, $userId);
$this->sendResponse(200, ['message' => 'Task updated']);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
public function destroy(int $id) {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$this->taskService->deleteTask($id, $userId);
$this->sendResponse(200, ['message' => 'Task deleted']);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
private function getRequestData(): array {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (strpos($contentType, 'application/json') !== false) {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
} elseif (strpos($contentType, 'multipart/form-data') !== false) {
return $_POST;
}
return [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
}
-
Key Features:
- Centralizes error handling to avoid repetitive
try-catch
blocks. - Maps exceptions to appropriate HTTP status codes.
- Logs errors to
logs/errors.log
for debugging.
- Centralizes error handling to avoid repetitive
12.2 Creating Custom Exception Classes
Custom exceptions allow us to handle specific error scenarios with precision.
-
Create
src/Exceptions/NotFoundException.php
:
namespace App\Exceptions;
class NotFoundException extends \RuntimeException {}
-
Create
src/Exceptions/ValidationException.php
:
namespace App\Exceptions;
class ValidationException extends \InvalidArgumentException {}
-
Update
TaskService.php
to Use Custom Exceptions:
namespace App\Services;
use App\DTO\TaskDTO;
use App\Entities\Task;
use App\Repositories\TaskRepository;
use App\Validation\Validator;
use App\Exceptions\NotFoundException;
use App\Exceptions\ValidationException;
class TaskService {
private $taskRepository;
public function __construct(TaskRepository $taskRepository) {
$this->taskRepository = $taskRepository;
}
public function createTask(array $data, int $userId): Task {
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new ValidationException(implode(', ', $errors));
}
$taskData = array_merge($dto->toArray(), ['user_id' => $userId]);
return $this->taskRepository->create($taskData);
}
public function updateTask(int $id, array $data, int $userId): bool {
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new ValidationException(implode(', ', $errors));
}
$task = $this->taskRepository->findById('tasks', $id);
if (!$task || $task->getUserId() !== $userId) {
throw new NotFoundException('Task not found or unauthorized');
}
return $this->taskRepository->update($id, $dto->toArray());
}
public function getTasksByUser(int $userId, ?string $status = null, int $limit = 10): array {
$tasks = $this->taskRepository->findByUserId($userId);
if ($status) {
$tasks = array_filter($tasks, fn($task) => $task->getStatus() === $status);
}
return array_slice($tasks, 0, $limit);
}
public function deleteTask(int $id, int $userId): bool {
$task = $this->taskRepository->findById('tasks', $id);
if (!$task || $task->getUserId() !== $userId) {
throw new NotFoundException('Task not found or unauthorized');
}
return $this->taskRepository->delete('tasks', $id);
}
}
-
Update
ErrorHandler.php
to Handle Custom Exceptions:
switch (get_class($exception)) {
case \InvalidArgumentException::class:
case \App\Exceptions\ValidationException::class:
$statusCode = 400;
$errorMessage = $exception->getMessage();
break;
case \RuntimeException::class:
$statusCode = 401;
$errorMessage = $exception->getMessage();
break;
case \App\Exceptions\NotFoundException::class:
$statusCode = 404;
$errorMessage = $exception->getMessage();
break;
}
-
Why Custom Exceptions?
- Specific exceptions (
NotFoundException
,ValidationException
) clarify error types. - Easier to map to HTTP status codes in
ErrorHandler
. - Improves code readability and maintainability.
- Specific exceptions (
12.3 Implementing Centralized Logging
Centralized logging records errors and API activity for debugging and monitoring.
-
Enhance
RequestLogger
Middleware (src/Middleware/RequestLogger.php
):
namespace App\Middleware;
class RequestLogger {
public function handle(callable $next) {
$log = sprintf(
"[%s] %s %s from %s\n",
date('Y-m-d H:i:s'),
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI'],
$_SERVER['REMOTE_ADDR']
);
file_put_contents(__DIR__ . '/../../logs/requests.log', $log, FILE_APPEND);
return $next();
}
}
-
Create a Log Directory:
- Create a
logs/
folder in the project root with write permissions (e.g.,chmod 775 logs
).
- Create a
-
Update
ErrorHandler
Logging (already included inlogError
method):- Logs detailed error information (exception type, message, file, line, stack trace).
-
Example Log Output (
logs/errors.log
):
[2025-10-12 17:03:00] App\Exceptions\ValidationException: Title is required in /src/Services/TaskService.php:20
Stack trace: ...
-
Example Log Output (
logs/requests.log
):
[2025-10-12 17:03:00] POST /api/tasks from 127.0.0.1
-
Why Centralized Logging?
- Tracks API usage and errors for debugging.
- Helps identify patterns (e.g., frequent validation errors).
- Can be extended to use advanced logging libraries (e.g., Monolog) in production.
12.4 Returning Meaningful Error Responses
Clients need clear, consistent error messages to understand what went wrong.
- Error Response Structure:
{
"error": "Detailed error message"
}
-
Example Responses:
- Validation Error (400):
{ "error": "Title must be between 3 and 100 characters, Due date format is invalid" }
- Not Found (404):
{ "error": "Task not found or unauthorized" }
- Unauthorized (401):
{ "error": "Invalid or expired token" }
Update
AuthController.php
to Use ErrorHandler:
namespace App\Controllers;
use App\Services\AuthService;
use App\Exceptions\ErrorHandler;
class AuthController {
private $authService;
public function __construct(AuthService $authService) {
$this->authService = $authService;
}
public function login() {
try {
$data = $this->getRequestData();
$email = $data['email'] ?? throw new \App\Exceptions\ValidationException('Email is required');
$password = $data['password'] ?? throw new \App\Exceptions\ValidationException('Password is required');
$result = $this->authService->login($email, $password);
$this->sendResponse(200, ['data' => $result, 'message' => 'Login successful']);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
public function refresh() {
try {
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
throw new \App\Exceptions\ValidationException('Missing Authorization header');
}
$token = $matches[1];
$result = $this->authService->refreshToken($token);
$this->sendResponse(200, ['data' => $result, 'message' => 'Token refreshed']);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
private function getRequestData(): array {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
}
Hands-On Activity
Let’s implement and test our error-handling and logging system!
-
Task 1: Implement ErrorHandler
- Create
ErrorHandler.php
,NotFoundException.php
, andValidationException.php
. - Update
TaskController.php
andAuthController.php
to useErrorHandler
.
- Create
-
Task 2: Test Error Responses
- Use Postman to test:
-
POST /api/tasks
with invalid data (e.g.,{"title": ""}
) to trigger a 400 error. -
GET /api/tasks/999
to trigger a 404 error. -
GET /api/tasks
without a valid JWT to trigger a 401 error. - Verify JSON responses match the expected format.
-
Task 3: Check Logs
- Send requests to trigger errors and check
logs/errors.log
for detailed entries. - Verify
logs/requests.log
records all requests with method, URI, and IP.
- Send requests to trigger errors and check
-
Task 4: Add a Custom Exception
- Create a
ForbiddenException
for unauthorized actions (e.g., updating another user’s task). - Update
TaskService
to throw it andErrorHandler
to map it to HTTP 403. - Test by attempting to update a task with a different user ID.
- Create a
-
Task 5: Update README
- Add a section to
README.md
describing the error-handling system, including example error responses and log file locations.
- Add a section to
Resources
- PHP Exception Handling: https://www.php.net/manual/en/language.exceptions.php
- HTTP Status Codes: https://restfulapi.net/http-status-codes/
- Logging in PHP: https://www.php.net/manual/en/function.error-log.php
- Monolog (Advanced Logging): https://github.com/Seldaek/monolog
- REST API Error Handling: https://www.rfc-editor.org/rfc/rfc7807 # Module 13: Database Transactions and Relationships
Overview
Welcome to Module 13 of Build a Robust RESTful API with PHP 8, from Scratch! We’re diving deeper into the Data Access Layer to enhance our Task Management API with database transactions and relationships. This module focuses on ensuring data integrity with transactions, handling one-to-many and many-to-many relationships, implementing relational queries in the Data Access Layer, and optimizing database performance with indexing. By the end, your API will manage complex data relationships reliably and efficiently, ready to scale with real-world demands. Let’s get started and make our database interactions rock-solid!
Learning Objectives
- Understand the importance of database transactions for data integrity.
- Implement one-to-many and many-to-many relationships in the database schema.
- Write relational queries in the Data Access Layer to handle related data.
- Optimize database performance using indexing strategies.
- Test transactions and relationships to ensure reliable data operations.
Content
13.1 Managing Database Transactions for Data Integrity
Database transactions ensure that a series of operations either all succeed or all fail, maintaining data consistency.
-
Why Transactions?
- Prevent partial updates (e.g., creating a task but failing to log the action).
- Ensure atomicity, consistency, isolation, and durability (ACID properties).
- Critical for operations involving multiple tables (e.g., updating a user and their tasks).
-
Using PDO Transactions:
- PDO supports transactions with
beginTransaction()
,commit()
, androllBack()
. - Wrap related database operations in a transaction block.
- PDO supports transactions with
-
Update
TaskRepository
to Use Transactions:
namespace App\Repositories;
use App\Entities\Task;
class TaskRepository extends BaseRepository {
public function create(array $data): Task {
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare(
'INSERT INTO tasks (user_id, title, description, due_date, status)
VALUES (:user_id, :title, :description, :due_date, :status)'
);
$stmt->execute([
'user_id' => $data['user_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
]);
$id = $this->db->lastInsertId();
$this->db->commit();
return $this->findById('tasks', $id);
} catch (\PDOException $e) {
$this->db->rollBack();
throw new \RuntimeException('Failed to create task: ' . $e->getMessage());
}
}
public function update($id, array $data): bool {
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare(
'UPDATE tasks
SET title = :title, description = :description, due_date = :due_date, status = :status
WHERE id = :id'
);
$result = $stmt->execute([
'id' => $id,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending'
]);
$this->db->commit();
return $result;
} catch (\PDOException $e) {
$this->db->rollBack();
throw new \RuntimeException('Failed to update task: ' . $e->getMessage());
}
}
}
-
Why Transactions Here?
- Ensures data consistency if additional operations (e.g., logging) are added.
- Prevents partial updates if an error occurs during query execution.
13.2 Handling One-to-Many and Many-to-Many Relationships
Our API needs to support relationships between entities, such as users owning multiple tasks (one-to-many) and tasks potentially belonging to multiple categories (many-to-many).
-
One-to-Many: Users and Tasks
- Already implemented: Each user can have multiple tasks (
user_id
foreign key intasks
table). - Example:
TaskRepository::findByUserId
retrieves all tasks for a user.
- Already implemented: Each user can have multiple tasks (
-
Many-to-Many: Tasks and Categories
- Add a
categories
table and atask_category
junction table. - Update Database Schema:
CREATE TABLE categories ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE task_category ( task_id INT NOT NULL, category_id INT NOT NULL, PRIMARY KEY (task_id, category_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE );
- Insert Sample Data:
INSERT INTO categories (name) VALUES ('Work'), ('Personal'); INSERT INTO task_category (task_id, category_id) VALUES (1, 1), (1, 2);
- Add a
Create
src/Entities/Category.php
:
namespace App\Entities;
class Category {
private $id;
private $name;
private $createdAt;
public function __construct(array $data = []) {
$this->id = $data['id'] ?? null;
$this->name = $data['name'] ?? null;
$this->createdAt = $data['created_at'] ?? null;
}
public function getId() { return $this->id; }
public function getName() { return $this->name; }
public function getCreatedAt() { return $this->createdAt; }
public function toArray(): array {
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->createdAt
];
}
}
-
Create
src/Repositories/CategoryRepository.php
:
namespace App\Repositories;
use App\Entities\Category;
class CategoryRepository extends BaseRepository {
public function create(array $data): Category {
$stmt = $this->db->prepare('INSERT INTO categories (name) VALUES (:name)');
$stmt->execute(['name' => $data['name']]);
$id = $this->db->lastInsertId();
return $this->findById('categories', $id);
}
public function findById(string $table, $id): ?Category {
$data = parent::findById($table, $id);
return $data ? new Category($data) : null;
}
public function findAll(string $table): array {
$rows = parent::findAll($table);
return array_map(fn($row) => new Category($row), $rows);
}
public function assignCategoryToTask(int $taskId, int $categoryId): bool {
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare('INSERT INTO task_category (task_id, category_id) VALUES (:task_id, :category_id)');
$result = $stmt->execute(['task_id' => $taskId, 'category_id' => $categoryId]);
$this->db->commit();
return $result;
} catch (\PDOException $e) {
$this->db->rollBack();
throw new \RuntimeException('Failed to assign category: ' . $e->getMessage());
}
}
public function getCategoriesForTask(int $taskId): array {
$stmt = $this->db->prepare(
'SELECT c.* FROM categories c
JOIN task_category tc ON c.id = tc.category_id
WHERE tc.task_id = :task_id'
);
$stmt->execute(['task_id' => $taskId]);
$rows = $stmt->fetchAll();
return array_map(fn($row) => new Category($row), $rows);
}
}
-
Why Relationships?
- One-to-Many: Allows users to have multiple tasks, retrieved efficiently via
user_id
. - Many-to-Many: Enables tasks to belong to multiple categories, supporting flexible tagging.
- One-to-Many: Allows users to have multiple tasks, retrieved efficiently via
13.3 Implementing Relational Queries in the Data Layer
Let’s enhance TaskRepository
to include category information in task queries.
-
Update
TaskRepository::findById
to Include Categories:
public function findById(string $table, $id): ?Task {
$stmt = $this->db->prepare(
'SELECT t.*, GROUP_CONCAT(c.id) as category_ids, GROUP_CONCAT(c.name) as category_names
FROM tasks t
LEFT JOIN task_category tc ON t.id = tc.task_id
LEFT JOIN categories c ON tc.category_id = c.id
WHERE t.id = :id
GROUP BY t.id'
);
$stmt->execute(['id' => $id]);
$data = $stmt->fetch();
if ($data) {
$data['categories'] = $data['category_ids'] ? array_map(
fn($id, $name) => new Category(['id' => $id, 'name' => $name]),
explode(',', $data['category_ids']),
explode(',', $data['category_names'])
) : [];
unset($data['category_ids'], $data['category_names']);
return new Task($data);
}
return null;
}
-
Update
Task::toArray
to Include Categories:
public function toArray(): array {
return [
'id' => $this->id,
'user_id' => $this->userId,
'title' => $this->title,
'description' => $this->description,
'due_date' => $this->dueDate,
'status' => $this->status,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
'categories' => array_map(fn($category) => $category->toArray(), $this->categories ?? [])
];
}
-
Add Categories Property to
Task.php
:
private $categories;
public function __construct(array $data = []) {
$this->id = $data['id'] ?? null;
$this->userId = $data['user_id'] ?? null;
$this->title = $data['title'] ?? null;
$this->description = $data['description'] ?? null;
$this->dueDate = $data['due_date'] ?? null;
$this->status = $data['status'] ?? 'pending';
$this->createdAt = $data['created_at'] ?? null;
$this->updatedAt = $data['updated_at'] ?? null;
$this->categories = $data['categories'] ?? [];
}
public function getCategories() { return $this->categories; }
public function setCategories(array $categories) { $this->categories = $categories; }
-
Why Relational Queries?
- Fetch related data (e.g., task categories) in a single query to reduce database calls.
- Use
LEFT JOIN
to include tasks without categories. -
GROUP_CONCAT
aggregates category data efficiently.
13.4 Optimizing Database Performance with Indexing
Indexes improve query performance, especially for frequently accessed columns.
- Add Indexes to Schema:
CREATE INDEX idx_user_id ON tasks(user_id);
CREATE INDEX idx_status ON tasks(status);
CREATE INDEX idx_due_date ON tasks(due_date);
-
Why Indexing?
- Speeds up queries like
SELECT * FROM tasks WHERE user_id = ?
. - Reduces load on the database for large datasets.
-
idx_user_id
optimizesfindByUserId
,idx_status
helps with filtering.
- Speeds up queries like
-
Performance Considerations:
- Indexes increase write time, so use them on frequently queried columns.
- Analyze query performance with
EXPLAIN
in MySQL to verify index usage.
Hands-On Activity
Let’s implement and test transactions and relationships!
-
Task 1: Set Up Relationships
- Update the database schema to add
categories
andtask_category
tables. - Insert sample categories and task-category relationships.
- Update the database schema to add
-
Task 2: Implement Transactions
- Update
TaskRepository.php
to use transactions forcreate
andupdate
. - Test by creating a task and intentionally causing an error (e.g., invalid
user_id
) to verify rollback.
- Update
-
Task 3: Test Relational Queries
- Implement
CategoryRepository.php
and updateTaskRepository::findById
. - Create a test script to fetch a task and its categories:
require_once __DIR__ . '/vendor/autoload.php'; use App\Repositories\TaskRepository; use App\Repositories\CategoryRepository; $taskRepo = new TaskRepository(); $categoryRepo = new CategoryRepository(); $categoryRepo->assignCategoryToTask(1, 1); $task = $taskRepo->findById('tasks', 1); print_r($task->toArray());
- Implement
-
Task 4: Optimize with Indexes
- Add indexes to
tasks
andtask_category
tables. - Run
EXPLAIN SELECT * FROM tasks WHERE user_id = 1;
to verify index usage.
- Add indexes to
-
Task 5: Update README
- Add a section to
README.md
describing transactions, relationships, and indexing. - Include example SQL for the schema and a sample task response with categories.
- Add a section to
Resources
- PDO Transactions: https://www.php.net/manual/en/pdo.transactions.php
- MySQL Indexes: https://dev.mysql.com/doc/refman/8.0/en/optimization-indexes.html
- Database Relationships: https://www.sqlshack.com/learn-sql-types-of-relations/
- MySQL EXPLAIN: https://dev.mysql.com/doc/refman/8.0/en/explain.html
- GROUP_CONCAT in MySQL: https://dev.mysql.com/doc/refman/8.0/en/aggregate-functions.html#function_group-concat # Module 14: Advanced API Features
Overview
Welcome to Module 14 of Build a Robust RESTful API with PHP 8, from Scratch! We’re now stepping up our game by adding advanced API features to our Task Management API. In this module, we’ll implement pagination to handle large datasets, support filtering and sorting for flexible queries, enable file uploads for task attachments, and add search functionality using full-text indexing. These features will make our API more powerful, user-friendly, and ready for real-world use cases. Let’s dive in and enhance our API with these pro-level capabilities!
Learning Objectives
- Implement pagination to efficiently handle large datasets.
- Add filtering and sorting capabilities to API responses.
- Enable file uploads and storage for task attachments.
- Integrate full-text search for efficient task retrieval.
- Test advanced features to ensure they work seamlessly.
Content
14.1 Implementing Pagination for Large Datasets
Pagination breaks large datasets into manageable chunks, improving performance and user experience.
-
Why Pagination?
- Reduces server load by returning only a subset of results.
- Improves client-side performance with smaller responses.
- Common in APIs (e.g.,
GET /tasks?page=2&limit=10
).
-
Update
TaskService
to Support Pagination:
namespace App\Services;
use App\DTO\TaskDTO;
use App\Entities\Task;
use App\Repositories\TaskRepository;
use App\Validation\Validator;
use App\Exceptions\NotFoundException;
use App\Exceptions\ValidationException;
class TaskService {
private $taskRepository;
public function __construct(TaskRepository $taskRepository) {
$this->taskRepository = $taskRepository;
}
public function getTasksByUser(int $userId, ?string $status = null, int $limit = 10, int $page = 1): array {
$tasks = $this->taskRepository->findByUserId($userId);
if ($status) {
$tasks = array_filter($tasks, fn($task) => $task->getStatus() === $status);
}
$offset = ($page - 1) * $limit;
$total = count($tasks);
$tasks = array_slice($tasks, $offset, $limit);
return [
'data' => $tasks,
'meta' => [
'total' => $total,
'page' => $page,
'limit' => $limit,
'total_pages' => ceil($total / $limit)
]
];
}
// Other methods (createTask, updateTask, deleteTask) unchanged
}
-
Update
TaskController::index
:
public function index() {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$page = (int)($_GET['page'] ?? 1);
$result = $this->taskService->getTasksByUser($userId, $status, $limit, $page);
$this->sendResponse(200, [
'data' => array_map(fn($task) => $task->toArray(), $result['data']),
'meta' => $result['meta']
]);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
-
Example Response for
GET /api/tasks?page=2&limit=5
:
{
"data": [
{"id": 6, "title": "Task 6", "status": "pending", ...},
{"id": 7, "title": "Task 7", "status": "pending", ...}
],
"meta": {
"total": 12,
"page": 2,
"limit": 5,
"total_pages": 3
}
}
-
Why This Approach?
- Returns metadata (
total
,page
,total_pages
) for client navigation. - Supports flexible
limit
andpage
query parameters.
- Returns metadata (
14.2 Supporting Filtering and Sorting in API Responses
Filtering and sorting allow clients to query specific data and order results as needed.
-
Filtering:
- Already implemented
status
filtering inTaskService::getTasksByUser
. - Add support for filtering by
due_date
range.
- Already implemented
-
Sorting:
- Support sorting by
title
,due_date
, orstatus
(e.g.,sort=title:asc
).
- Support sorting by
-
Update
TaskRepository
to Handle Filtering and Sorting:
public function findByUserId(int $userId, ?string $status = null, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$query = 'SELECT t.*, GROUP_CONCAT(c.id) as category_ids, GROUP_CONCAT(c.name) as category_names
FROM tasks t
LEFT JOIN task_category tc ON t.id = tc.task_id
LEFT JOIN categories c ON tc.category_id = c.id
WHERE t.user_id = :user_id';
$params = ['user_id' => $userId];
if ($status) {
$query .= ' AND t.status = :status';
$params['status'] = $status;
}
if ($dueDateStart) {
$query .= ' AND t.due_date >= :due_date_start';
$params['due_date_start'] = $dueDateStart;
}
if ($dueDateEnd) {
$query .= ' AND t.due_date <= :due_date_end';
$params['due_date_end'] = $dueDateEnd;
}
if ($sort) {
[$field, $direction] = explode(':', $sort);
$field = in_array($field, ['title', 'due_date', 'status']) ? $field : 'created_at';
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$query .= " ORDER BY t.$field $direction";
}
$query .= ' GROUP BY t.id';
$stmt = $this->db->prepare($query);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(function ($row) {
$row['categories'] = $row['category_ids'] ? array_map(
fn($id, $name) => new Category(['id' => $id, 'name' => $name]),
explode(',', $row['category_ids']),
explode(',', $row['category_names'])
) : [];
unset($row['category_ids'], $row['category_names']);
return new Task($row);
}, $rows);
}
-
Update
TaskService::getTasksByUser
:
public function getTasksByUser(int $userId, ?string $status = null, int $limit = 10, int $page = 1, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$tasks = $this->taskRepository->findByUserId($userId, $status, $dueDateStart, $dueDateEnd, $sort);
$offset = ($page - 1) * $limit;
$total = count($tasks);
$tasks = array_slice($tasks, $offset, $limit);
return [
'data' => $tasks,
'meta' => [
'total' => $total,
'page' => $page,
'limit' => $limit,
'total_pages' => ceil($total / $limit)
]
];
}
-
Update
TaskController::index
:
public function index() {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$page = (int)($_GET['page'] ?? 1);
$dueDateStart = $_GET['due_date_start'] ?? null;
$dueDateEnd = $_GET['due_date_end'] ?? null;
$sort = $_GET['sort'] ?? null;
$result = $this->taskService->getTasksByUser($userId, $status, $limit, $page, $dueDateStart, $dueDateEnd, $sort);
$this->sendResponse(200, [
'data' => array_map(fn($task) => $task->toArray(), $result['data']),
'meta' => $result['meta']
]);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
-
Example Request:
GET /api/tasks?status=pending&due_date_start=2025-10-01&due_date_end=2025-10-31&sort=title:asc&limit=5&page=1
- Returns pending tasks due in October 2025, sorted by title, with pagination metadata.
14.3 Handling File Uploads and Storage
Allowing file uploads (e.g., task attachments) enhances functionality for tasks like uploading documents or images.
- Update Database Schema:
ALTER TABLE tasks ADD attachment VARCHAR(255) DEFAULT NULL;
-
Update
Task.php
:
private $attachment;
public function __construct(array $data = []) {
// ... other properties
$this->attachment = $data['attachment'] ?? null;
}
public function getAttachment() { return $this->attachment; }
public function setAttachment(?string $attachment) { $this->attachment = $attachment; }
public function toArray(): array {
return [
// ... other fields
'attachment' => $this->attachment,
'categories' => array_map(fn($category) => $category->toArray(), $this->categories ?? [])
];
}
-
Create
src/Utils/FileUploader.php
:
namespace App\Utils;
class FileUploader {
public static function upload(array $file, string $directory = 'uploads'): ?string {
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new \RuntimeException('File upload failed');
}
$allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
if (!in_array($file['type'], $allowedTypes)) {
throw new \InvalidArgumentException('Invalid file type');
}
$maxSize = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $maxSize) {
throw new \InvalidArgumentException('File too large');
}
$filename = uniqid() . '-' . basename($file['name']);
$targetPath = __DIR__ . '/../../uploads/' . $filename;
if (!is_dir(dirname($targetPath))) {
mkdir(dirname($targetPath), 0755, true);
}
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new \RuntimeException('Failed to move uploaded file');
}
return $filename;
}
}
-
Update
TaskService::createTask
:
public function createTask(array $data, int $userId): Task {
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new \App\Exceptions\ValidationException(implode(', ', $errors));
}
if (isset($_FILES['attachment'])) {
$data['attachment'] = FileUploader::upload($_FILES['attachment']);
}
$taskData = array_merge($dto->toArray(), ['user_id' => $userId]);
return $this->taskRepository->create($taskData);
}
-
Update
TaskRepository::create
:
public function create(array $data): Task {
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare(
'INSERT INTO tasks (user_id, title, description, due_date, status, attachment)
VALUES (:user_id, :title, :description, :due_date, :status, :attachment)'
);
$stmt->execute([
'user_id' => $data['user_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'due_date' => $data['due_date'] ?? null,
'status' => $data['status'] ?? 'pending',
'attachment' => $data['attachment'] ?? null
]);
$id = $this->db->lastInsertId();
$this->db->commit();
return $this->findById('tasks', $id);
} catch (\PDOException $e) {
$this->db->rollBack();
throw new \RuntimeException('Failed to create task: ' . $e->getMessage());
}
}
-
Serve Uploaded Files:
- Create
public/uploads.php
:
$filename = $_GET['file'] ?? ''; $filePath = __DIR__ . '/../uploads/' . basename($filename); if (file_exists($filePath)) { header('Content-Type: ' . mime_content_type($filePath)); readfile($filePath); } else { http_response_code(404); echo 'File not found'; }
- Example URL:
http://localhost:8000/uploads.php?file=filename.jpg
- Create
14.4 Adding Search Functionality with Full-Text Indexing
Full-text search enables efficient searching of task titles and descriptions.
- Add Full-Text Index:
ALTER TABLE tasks ADD FULLTEXT(title, description);
-
Update
TaskRepository
to Support Search:
public function searchByUserId(int $userId, string $query, ?string $status = null, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$sql = 'SELECT t.*, GROUP_CONCAT(c.id) as category_ids, GROUP_CONCAT(c.name) as category_names
FROM tasks t
LEFT JOIN task_category tc ON t.id = tc.task_id
LEFT JOIN categories c ON tc.category_id = c.id
WHERE t.user_id = :user_id
AND MATCH(t.title, t.description) AGAINST(:query IN BOOLEAN MODE)';
$params = ['user_id' => $userId, 'query' => $query];
if ($status) {
$sql .= ' AND t.status = :status';
$params['status'] = $status;
}
if ($dueDateStart) {
$sql .= ' AND t.due_date >= :due_date_start';
$params['due_date_start'] = $dueDateStart;
}
if ($dueDateEnd) {
$sql .= ' AND t.due_date <= :due_date_end';
$params['due_date_end'] = $dueDateEnd;
}
if ($sort) {
[$field, $direction] = explode(':', $sort);
$field = in_array($field, ['title', 'due_date', 'status']) ? $field : 'created_at';
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$sql .= " ORDER BY t.$field $direction";
}
$sql .= ' GROUP BY t.id';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(function ($row) {
$row['categories'] = $row['category_ids'] ? array_map(
fn($id, $name) => new Category(['id' => $id, 'name' => $name]),
explode(',', $row['category_ids']),
explode(',', $row['category_names'])
) : [];
unset($row['category_ids'], $row['category_names']);
return new Task($row);
}, $rows);
}
-
Update
TaskService
:
public function searchTasks(int $userId, string $query, ?string $status = null, int $limit = 10, int $page = 1, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$tasks = $this->taskRepository->searchByUserId($userId, $query, $status, $dueDateStart, $dueDateEnd, $sort);
$offset = ($page - 1) * $limit;
$total = count($tasks);
$tasks = array_slice($tasks, $offset, $limit);
return [
'data' => $tasks,
'meta' => [
'total' => $total,
'page' => $page,
'limit' => $limit,
'total_pages' => ceil($total / $limit)
]
];
}
-
Add Search Endpoint to
TaskController
:
public function search() {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$query = $_GET['query'] ?? throw new \App\Exceptions\ValidationException('Search query is required');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$page = (int)($_GET['page'] ?? 1);
$dueDateStart = $_GET['due_date_start'] ?? null;
$dueDateEnd = $_GET['due_date_end'] ?? null;
$sort = $_GET['sort'] ?? null;
$result = $this->taskService->searchTasks($userId, $query, $status, $limit, $page, $dueDateStart, $dueDateEnd, $sort);
$this->sendResponse(200, [
'data' => array_map(fn($task) => $task->toArray(), $result['data']),
'meta' => $result['meta']
]);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
-
Update
index.php
:
$router->get('/tasks/search', fn() => $taskController->search(), [RequestLogger::class, AuthMiddleware::class]);
Hands-On Activity
Let’s implement and test these advanced features!
-
Task 1: Implement Pagination
- Update
TaskService
,TaskController
, andTaskRepository
for pagination. - Test
GET /api/tasks?page=2&limit=5
in Postman and verify metadata.
- Update
-
Task 2: Test Filtering and Sorting
- Send
GET /api/tasks?status=pending&due_date_start=2025-10-01&due_date_end=2025-10-31&sort=title:asc
. - Verify filtered and sorted results with correct pagination.
- Send
-
Task 3: Implement File Uploads
- Add the
attachment
column to thetasks
table. - Create
FileUploader.php
and updateTaskService
andTaskRepository
. - Test
POST /api/tasks
with a file upload (use Postman’s form-data option). - Access the uploaded file via
GET /uploads.php?file=filename.jpg
.
- Add the
-
Task 4: Implement Search
- Add the full-text index and implement the search endpoint.
- Test
GET /api/tasks/search?query=meeting
to find tasks with “meeting” in title or description.
-
Task 5: Update README
- Add a section to
README.md
describing pagination, filtering, sorting, file uploads, and search. - Include example requests and responses for each feature.
- Add a section to
Resources
- MySQL Full-Text Search: https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html
- PHP File Uploads: https://www.php.net/manual/en/features.file-upload.php
- REST API Pagination: https://restfulapi.net/pagination/
- MySQL Indexing: https://dev.mysql.com/doc/refman/8.0/en/optimization-indexes.html
- Postman File Uploads: https://learning.postman.com/docs/sending-requests/requests/#form-data # Module 15: API Versioning and Backward Compatibility
Overview
Welcome to Module 15 of Build a Robust RESTful API with PHP 8, from Scratch! As our Task Management API evolves, we need to ensure it remains stable for existing clients while introducing new features. This module focuses on API versioning and backward compatibility, critical for maintaining a reliable API in production. We’ll explore strategies for versioning RESTful APIs, implement versioned endpoints (e.g., /v1/tasks
), manage deprecated endpoints, and ensure backward compatibility to avoid breaking client applications. By the end, your API will be future-proof and client-friendly. Let’s dive in and make our API adaptable!
Learning Objectives
- Understand the importance of API versioning and backward compatibility.
- Explore strategies for versioning RESTful APIs (URI, header, query parameter).
- Implement versioned endpoints in the Task Management API.
- Manage deprecated endpoints with clear communication and graceful phase-out.
- Ensure backward compatibility to support existing clients during updates.
Content
15.1 Strategies for Versioning RESTful APIs
API versioning allows you to introduce changes without breaking existing clients. Common strategies include:
-
URI Versioning:
- Include version in the URL (e.g.,
/v1/tasks
). - Pros: Simple, explicit, easy to route.
- Cons: Clutters URLs, harder to change versioning strategy later.
- Example: GitHub API (
api.github.com/v3
).
- Include version in the URL (e.g.,
-
Header Versioning:
- Specify version in a custom header (e.g.,
Accept: application/vnd.api+json; version=1.0
). - Pros: Keeps URLs clean, flexible for content negotiation.
- Cons: Less discoverable, requires client to set headers.
- Example: GitHub’s GraphQL API.
- Specify version in a custom header (e.g.,
-
Query Parameter Versioning:
- Use a query parameter (e.g.,
/tasks?version=1
). - Pros: Simple to implement, keeps URLs stable.
- Cons: Less common, can be ignored by clients.
- Use a query parameter (e.g.,
-
Chosen Strategy for Our API:
- We’ll use URI versioning (
/v1/tasks
) for simplicity and clarity. - Reason: It’s intuitive for clients and aligns with common REST practices.
- We’ll use URI versioning (
-
Versioning Best Practices:
- Increment versions for breaking changes (e.g., v1 to v2).
- Use semantic versioning (e.g., v1.0, v1.1) for clarity.
- Document version changes clearly in the API documentation.
15.2 Implementing Versioned Endpoints
We’ll update our router and folder structure to support versioned endpoints (e.g., /v1/tasks
).
-
Update Folder Structure:
- Restructure
src/
to support versioning:
src/ ├── Controllers/ │ ├── v1/ │ │ ├── TaskController.php │ │ ├── UserController.php │ │ └── AuthController.php ├── Services/ ├── Repositories/ ├── Entities/ ├── DTO/ ├── Validation/ ├── Middleware/ ├── Utils/ ├── Exceptions/ └── Routing/
- Restructure
Update Namespace in
TaskController.php
(and others):
namespace App\Controllers\v1;
use App\Exceptions\ErrorHandler;
use App\Services\TaskService;
class TaskController {
private $taskService;
public function __construct(TaskService $taskService) {
$this->taskService = $taskService;
}
public function index() {
try {
$userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated');
$status = $_GET['status'] ?? null;
$limit = (int)($_GET['limit'] ?? 10);
$page = (int)($_GET['page'] ?? 1);
$dueDateStart = $_GET['due_date_start'] ?? null;
$dueDateEnd = $_GET['due_date_end'] ?? null;
$sort = $_GET['sort'] ?? null;
$result = $this->taskService->getTasksByUser($userId, $status, $limit, $page, $dueDateStart, $dueDateEnd, $sort);
$this->sendResponse(200, [
'data' => array_map(fn($task) => $task->toArray(), $result['data']),
'meta' => $result['meta']
]);
} catch (\Throwable $e) {
ErrorHandler::handle($e);
}
}
// Other methods (show, store, update, destroy, search) unchanged, update namespace
private function getRequestData(): array {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (strpos($contentType, 'application/json') !== false) {
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
} elseif (strpos($contentType, 'multipart/form-data') !== false) {
return $_POST;
}
return [];
}
private function sendResponse(int $status, array $data) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
}
-
Update
composer.json
for Autoloading:
"autoload": {
"psr-4": {
"App\\Controllers\\v1\\": "src/Controllers/v1/",
"App\\Services\\": "src/Services/",
"App\\Repositories\\": "src/Repositories/",
"App\\Entities\\": "src/Entities/",
"App\\DTO\\": "src/DTO/",
"App\\Validation\\": "src/Validation/",
"App\\Middleware\\": "src/Middleware/",
"App\\Utils\\": "src/Utils/",
"App\\Exceptions\\": "src/Exceptions/"
}
}
- Run
composer dump-autoload
.-
Update
Router.php
to Support Versioned Routes:
-
Update
namespace App\Routing;
class Router {
private $routes = [];
private $middleware = [];
public function get(string $path, callable $callback, array $middleware = []) {
$this->routes['GET'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function post(string $path, callable $callback, array $middleware = []) {
$this->routes['POST'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function put(string $path, callable $callback, array $middleware = []) {
$this->routes['PUT'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function delete(string $path, callable $callback, array $middleware = []) {
$this->routes['DELETE'][$path] = ['callback' => $callback, 'middleware' => $middleware];
}
public function dispatch() {
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Support versioned routes (e.g., /v1/tasks)
foreach ($this->routes[$method] ?? [] as $route => $handler) {
$pattern = preg_replace('#\{([\w]+)\}#', '([^/]+)', $route);
$pattern = "#^/v1$pattern$#"; // Add /v1 prefix
if (preg_match($pattern, $uri, $matches)) {
array_shift($matches);
$callback = $handler['callback'];
$middleware = $handler['middleware'];
// Run middleware
$next = fn() => call_user_func_array($callback, $matches);
foreach (array_reverse($middleware) as $mwClass) {
$mw = new $mwClass();
$next = fn() => $mw->handle($next);
}
return $next();
}
}
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'Route not found']);
}
}
-
Update
public/index.php
:
require_once __DIR__ . '/../vendor/autoload.php';
use App\Routing\Router;
use App\Controllers\v1\TaskController;
use App\Controllers\v1\UserController;
use App\Controllers\v1\AuthController;
use App\Services\TaskService;
use App\Services\UserService;
use App\Services\AuthService;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
use App\Repositories\CategoryRepository;
use App\Middleware\RequestLogger;
use App\Middleware\AuthMiddleware;
$router = new Router();
// Auth Routes
$authController = new AuthController(new AuthService(new UserRepository()));
$router->post('/auth/login', fn() => $authController->login(), [RequestLogger::class]);
$router->post('/auth/refresh', fn() => $authController->refresh(), [RequestLogger::class]);
// Task Routes
$taskController = new TaskController(new TaskService(new TaskRepository()));
$router->get('/tasks', fn() => $taskController->index(), [RequestLogger::class, AuthMiddleware::class]);
$router->get('/tasks/(\d+)', fn($id) => $taskController->show($id), [RequestLogger::class, AuthMiddleware::class]);
$router->post('/tasks', fn() => $taskController->store(), [RequestLogger::class, AuthMiddleware::class]);
$router->put('/tasks/(\d+)', fn($id) => $taskController->update($id), [RequestLogger::class, AuthMiddleware::class]);
$router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id), [RequestLogger::class, AuthMiddleware::class]);
$router->get('/tasks/search', fn() => $taskController->search(), [RequestLogger::class, AuthMiddleware::class]);
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/users', fn() => $userController->store(), [RequestLogger::class]);
$router->get('/users/(\d+)', fn($id) => $userController->show($id), [RequestLogger::class, AuthMiddleware::class]);
$router->dispatch();
-
Key Changes:
- Routes now use
/v1
prefix (e.g.,/v1/tasks
instead of/api/tasks
). - Controllers moved to
v1
namespace to support future versions (e.g.,v2
). - Router updated to match
/v1
paths.
- Routes now use
15.3 Managing Deprecated Endpoints
Deprecating endpoints ensures a smooth transition for clients when introducing breaking changes.
-
Deprecation Strategy:
- Announce deprecation in API documentation and response headers.
- Provide a sunset period (e.g., 6 months) before removing endpoints.
- Return deprecation warnings in responses for old endpoints.
-
Implement a Deprecated Endpoint:
- For demonstration, let’s assume
/api/tasks
(unversioned) is deprecated in favor of/v1/tasks
. - Update
Router.php
to Handle Deprecated Routes:
public function dispatch() { $method = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // Handle deprecated routes $deprecatedRoutes = [ 'GET' => ['/tasks' => '/v1/tasks', '/tasks/(\d+)' => '/v1/tasks/$1'], 'POST' => ['/tasks' => '/v1/tasks'], 'PUT' => ['/tasks/(\d+)' => '/v1/tasks/$1'], 'DELETE' => ['/tasks/(\d+)' => '/v1/tasks/$1'] ]; foreach ($deprecatedRoutes[$method] ?? [] as $oldRoute => $newRoute) { $pattern = "#^$oldRoute$#"; if (preg_match($pattern, $uri, $matches)) { array_shift($matches); $newUri = preg_replace('#\{([\w]+)\}#', '$1', $newRoute); $newUri = vsprintf($newUri, $matches); header('X-Deprecated: This endpoint is deprecated. Use ' . $newUri . ' instead.'); // Redirect to new route (temporary) http_response_code(301); header('Location: /v1' . $newUri); return; } } // Normal routing for /v1 foreach ($this->routes[$method] ?? [] as $route => $handler) { $pattern = preg_replace('#\{([\w]+)\}#', '([^/]+)', $route); $pattern = "#^/v1$pattern$#"; if (preg_match($pattern, $uri, $matches)) { array_shift($matches); $callback = $handler['callback']; $middleware = $handler['middleware']; $next = fn() => call_user_func_array($callback, $matches); foreach (array_reverse($middleware) as $mwClass) { $mw = new $mwClass(); $next = fn() => $mw->handle($next); } return $next(); } } http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'Route not found']); }
- For demonstration, let’s assume
-
Deprecation Header:
- Adds
X-Deprecated
header to warn clients about old endpoints. - Redirects to the new
/v1
endpoint for seamless transition.
- Adds
-
Why Deprecation Management?
- Gives clients time to update to new endpoints.
- Maintains trust by avoiding sudden breaking changes.
15.4 Ensuring Backward Compatibility
Backward compatibility ensures existing clients continue working after updates.
-
Strategies for Backward Compatibility:
- Avoid removing fields or endpoints without a deprecation period.
- Add new fields as optional to existing responses.
- Use default values for new required parameters.
- Support old response formats during transition.
-
Example: Adding a New Field
- Add a
priority
field to tasks without breaking existing clients. - Update Database Schema:
ALTER TABLE tasks ADD priority ENUM('low', 'medium', 'high') DEFAULT 'medium';
-
Update
Task.php
:
private $priority; public function __construct(array $data = []) { $this->id = $data['id'] ?? null; $this->userId = $data['user_id'] ?? null; $this->title = $data['title'] ?? null; $this->description = $data['description'] ?? null; $this->dueDate = $data['due_date'] ?? null; $this->status = $data['status'] ?? 'pending'; $this->createdAt = $data['created_at'] ?? null; $this->updatedAt = $data['updated_at'] ?? null; $this->categories = $data['categories'] ?? []; $this->attachment = $data['attachment'] ?? null; $this->priority = $data['priority'] ?? 'medium'; } public function getPriority() { return $this->priority; } public function setPriority(string $priority) { $this->priority = $priority; } public function toArray(): array { return [ 'id' => $this->id, 'user_id' => $this->userId, 'title' => $this->title, 'description' => $this->description, 'due_date' => $this->dueDate, 'status' => $this->status, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, 'categories' => array_map(fn($category) => $category->toArray(), $this->categories ?? []), 'attachment' => $this->attachment, 'priority' => $this->priority ]; }
-
Update
TaskDTO.php
:
use App\Validation\Attributes\Validate; class TaskDTO { #[Validate(required: true, minLength: 3, maxLength: 100)] public string $title; #[Validate] public ?string $description = null; #[Validate(pattern: '/^\d{4}-\d{2}-\d{2}$/')] #[DueDateNotPast] public ?string $dueDate = null; #[Validate(allowedValues: ['pending', 'in_progress', 'completed'])] public ?string $status = 'pending'; #[Validate(allowedValues: ['low', 'medium', 'high'])] public ?string $priority = 'medium'; public function __construct(array $data) { $this->title = $data['title'] ?? ''; $this->description = $data['description'] ?? null; $this->dueDate = $data['due_date'] ?? null; $this->status = $data['status'] ?? 'pending'; $this->priority = $data['priority'] ?? 'medium'; } public function toArray(): array { return [ 'title' => $this->title, 'description' => $this->description, 'due_date' => $this->dueDate, 'status' => $this->status, 'priority' => $this->priority ]; } }
-
Update
TaskRepository::create
andupdate
:
public function create(array $data): Task { $this->db->beginTransaction(); try { $stmt = $this->db->prepare( 'INSERT INTO tasks (user_id, title, description, due_date, status, attachment, priority) VALUES (:user_id, :title, :description, :due_date, :status, :attachment, :priority)' ); $stmt->execute([ 'user_id' => $data['user_id'], 'title' => $data['title'], 'description' => $data['description'] ?? null, 'due_date' => $data['due_date'] ?? null, 'status' => $data['status'] ?? 'pending', 'attachment' => $data['attachment'] ?? null, 'priority' => $data['priority'] ?? 'medium' ]); $id = $this->db->lastInsertId(); $this->db->commit(); return $this->findById('tasks', $id); } catch (\PDOException $e) { $this->db->rollBack(); throw new \RuntimeException('Failed to create task: ' . $e->getMessage()); } } public function update($id, array $data): bool { $this->db->beginTransaction(); try { $stmt = $this->db->prepare( 'UPDATE tasks SET title = :title, description = :description, due_date = :due_date, status = :status, priority = :priority WHERE id = :id' ); $result = $stmt->execute([ 'id' => $id, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'due_date' => $data['due_date'] ?? null, 'status' => $data['status'] ?? 'pending', 'priority' => $data['priority'] ?? 'medium' ]); $this->db->commit(); return $result; } catch (\PDOException $e) { $this->db->rollBack(); throw new \RuntimeException('Failed to update task: ' . $e->getMessage()); } }
- Add a
-
Why This Ensures Compatibility?
-
priority
is optional with a default value (medium
), so existing clients aren’t affected. - New clients can use
priority
without requiring immediate updates to old clients.
-
Hands-On Activity
Let’s implement and test versioning and backward compatibility!
-
Task 1: Implement Versioned Endpoints
- Update the folder structure and
composer.json
forv1
controllers. - Modify
Router.php
andindex.php
to support/v1
routes. - Test
GET /v1/tasks
andPOST /v1/tasks
in Postman to verify functionality.
- Update the folder structure and
-
Task 2: Test Deprecated Endpoints
- Send a request to
GET /api/tasks
(deprecated) in Postman. - Verify the
X-Deprecated
header and 301 redirect to/v1/tasks
.
- Send a request to
-
Task 3: Add Backward-Compatible Field
- Update the database schema to add
priority
to thetasks
table. - Update
Task.php
,TaskDTO.php
, andTaskRepository.php
to handlepriority
. - Test
POST /v1/tasks
with and withoutpriority
to confirm compatibility.
- Update the database schema to add
-
Task 4: Simulate a Breaking Change
- Create a mock
v2
controller (e.g.,src/Controllers/v2/TaskController.php
) that changes the response format (e.g., renamesdue_date
todeadline
). - Update
Router.php
to support/v2
routes and test both/v1
and/v2
endpoints.
- Create a mock
-
Task 5: Update README
- Add a section to
README.md
describing the versioning strategy, deprecated endpoints, and backward compatibility approach. - Include example requests for
/v1/tasks
and a note about the deprecated/api/tasks
.
- Add a section to
Resources
- REST API Versioning: https://restfulapi.net/versioning/
- Semantic Versioning: https://semver.org/
- HTTP Headers for Deprecation: https://tools.ietf.org/html/draft-dalal-deprecation-header-03
- Backward Compatibility Best Practices: https://www.mnot.net/blog/2012/12/04/api-evolution
- Postman Testing: https://learning.postman.com/docs/sending-requests/requests/ # Module 16: Testing the API
Overview
Welcome to Module 16 of Build a Robust RESTful API with PHP 8, from Scratch! We’ve built a feature-rich Task Management API, and now it’s time to ensure it’s reliable and robust through testing. In this module, we’ll explore different types of API testing—unit, integration, and end-to-end—set up PHPUnit for automated testing, write tests for controllers, services, and repositories, and use Postman for manual and automated API testing. By the end, you’ll have a comprehensive test suite to validate your API’s functionality and catch issues early. Let’s dive in and make our API bulletproof!
Learning Objectives
- Understand the differences between unit, integration, and end-to-end testing.
- Set up PHPUnit to automate testing for the API.
- Write unit tests for services and repositories, and integration tests for controllers.
- Use Postman for manual testing and automated test scripts.
- Test edge cases and error scenarios to ensure API reliability.
Content
16.1 Introduction to API Testing
Testing ensures our API behaves as expected, handles errors gracefully, and remains maintainable.
-
Types of Testing:
- Unit Testing: Tests individual components (e.g., a service method) in isolation, mocking dependencies.
- Integration Testing: Tests interactions between components (e.g., controller calling a service and repository).
- End-to-End (E2E) Testing: Tests the entire API flow, simulating real client requests.
-
Why Test?
- Catches bugs before they reach production.
- Ensures new features don’t break existing functionality.
- Simplifies refactoring by verifying behavior remains consistent.
-
Our Approach:
- Use PHPUnit for unit and integration tests.
- Use Postman for manual and automated E2E tests.
- Focus on testing controllers, services, and repositories.
16.2 Setting Up PHPUnit for Testing
PHPUnit is a powerful PHP testing framework for automating unit and integration tests.
- Install PHPUnit:
composer require --dev phpunit/phpunit ^10.0
composer require --dev mockery/mockery ^1.6
-
Configure PHPUnit:
- Create
phpunit.xml
in the project root:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="tests/bootstrap.php" colors="true"> <testsuites> <testsuite name="Task Management API Test Suite"> <directory>tests</directory> </testsuite> </testsuites> <php> <env name="APP_ENV" value="testing"/> <env name="DB_HOST" value="localhost"/> <env name="DB_NAME" value="task_management_test"/> <env name="DB_USER" value="root"/> <env name="DB_PASS" value=""/> <env name="JWT_SECRET" value="your-secure-jwt-secret"/> <env name="JWT_EXPIRY" value="3600"/> </php> </phpunit>
- Create
-
Create a Test Database:
- Create a
task_management_test
database and apply the schema from Module 13:
CREATE DATABASE task_management_test; USE task_management_test; CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE tasks ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, due_date DATE, status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending', attachment VARCHAR(255) DEFAULT NULL, priority ENUM('low', 'medium', 'high') DEFAULT 'medium', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FULLTEXT (title, description) ); CREATE TABLE categories ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE task_category ( task_id INT NOT NULL, category_id INT NOT NULL, PRIMARY KEY (task_id, category_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ); CREATE INDEX idx_user_id ON tasks(user_id); CREATE INDEX idx_status ON tasks(status); CREATE INDEX idx_due_date ON tasks(due_date);
- Create a
Create Test Directory Structure:
tests/
├── Unit/
│ ├── TaskServiceTest.php
│ ├── UserServiceTest.php
│ └── TaskRepositoryTest.php
├── Integration/
│ └── TaskControllerTest.php
└── bootstrap.php
-
Create
tests/bootstrap.php
:
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Initialize test database
$pdo = new PDO(
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']}",
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Clear test database
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
$pdo->exec('TRUNCATE TABLE task_category');
$pdo->exec('TRUNCATE TABLE tasks');
$pdo->exec('TRUNCATE TABLE categories');
$pdo->exec('TRUNCATE TABLE users');
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
- Run Tests:
vendor/bin/phpunit
16.3 Writing Tests for Services and Repositories
Unit tests focus on isolated components, mocking dependencies to ensure fast and reliable tests.
-
Unit Test for
TaskService
(tests/Unit/TaskServiceTest.php
):
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\TaskService;
use App\Repositories\TaskRepository;
use App\Entities\Task;
use App\Exceptions\ValidationException;
use Mockery;
class TaskServiceTest extends TestCase {
protected $taskRepository;
protected $taskService;
protected function setUp(): void {
$this->taskRepository = Mockery::mock(TaskRepository::class);
$this->taskService = new TaskService($this->taskRepository);
}
protected function tearDown(): void {
Mockery::close();
}
public function testCreateTaskSuccess() {
$data = [
'title' => 'Test Task',
'description' => 'Test Description',
'due_date' => '2025-10-25',
'status' => 'pending',
'priority' => 'medium'
];
$userId = 1;
$task = new Task(array_merge($data, ['id' => 1, 'user_id' => $userId]));
$this->taskRepository
->shouldReceive('create')
->once()
->with(array_merge($data, ['user_id' => $userId]))
->andReturn($task);
$result = $this->taskService->createTask($data, $userId);
$this->assertInstanceOf(Task::class, $result);
$this->assertEquals('Test Task', $result->getTitle());
}
public function testCreateTaskInvalidTitle() {
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Title must be between 3 and 100 characters');
$this->taskService->createTask(['title' => 'a'], 1);
}
public function testUpdateTaskNotFound() {
$this->taskRepository
->shouldReceive('findById')
->once()
->with('tasks', 999)
->andReturn(null);
$this->expectException(\App\Exceptions\NotFoundException::class);
$this->expectExceptionMessage('Task not found or unauthorized');
$this->taskService->updateTask(999, ['title' => 'Updated'], 1);
}
}
-
Unit Test for
UserService
(tests/Unit/UserServiceTest.php
):
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\UserService;
use App\Repositories\UserRepository;
use App\Entities\User;
use Mockery;
class UserServiceTest extends TestCase {
protected $userRepository;
protected $userService;
protected function setUp(): void {
$this->userRepository = Mockery::mock(UserRepository::class);
$this->userService = new UserService($this->userRepository);
}
protected function tearDown(): void {
Mockery::close();
}
public function testCreateUserSuccess() {
$data = [
'username' => 'testuser',
'email' => 'test@example.com',
'password' => 'secure123'
];
$user = new User(array_merge($data, ['id' => 1, 'password' => password_hash('secure123', PASSWORD_DEFAULT)]));
$this->userRepository
->shouldReceive('findByEmail')
->once()
->with($data['email'])
->andReturn(null);
$this->userRepository
->shouldReceive('create')
->once()
->with($data)
->andReturn($user);
$result = $this->userService->createUser($data);
$this->assertInstanceOf(User::class, $result);
$this->assertEquals('testuser', $result->getUsername());
}
public function testCreateUserDuplicateEmail() {
$data = [
'username' => 'testuser',
'email' => 'test@example.com',
'password' => 'secure123'
];
$existingUser = new User(['email' => 'test@example.com']);
$this->userRepository
->shouldReceive('findByEmail')
->once()
->with($data['email'])
->andReturn($existingUser);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Email already exists');
$this->userService->createUser($data);
}
}
-
Unit Test for
TaskRepository
(tests/Unit/TaskRepositoryTest.php
):
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Repositories\TaskRepository;
use App\Config\Database;
use App\Entities\Task;
class TaskRepositoryTest extends TestCase {
protected $db;
protected $taskRepository;
protected function setUp(): void {
$this->db = Database::getInstance();
$this->taskRepository = new TaskRepository();
$this->db->exec('INSERT INTO users (id, username, email, password) VALUES (1, "testuser", "test@example.com", "hashed")');
}
protected function tearDown(): void {
$this->db->exec('DELETE FROM tasks');
$this->db->exec('DELETE FROM users');
}
public function testCreateTask() {
$data = [
'user_id' => 1,
'title' => 'Test Task',
'description' => 'Test Description',
'due_date' => '2025-10-25',
'status' => 'pending',
'priority' => 'medium'
];
$task = $this->taskRepository->create($data);
$this->assertInstanceOf(Task::class, $task);
$this->assertEquals('Test Task', $task->getTitle());
}
}
-
Key Features:
- Uses Mockery to mock repositories in service tests, isolating dependencies.
- Tests success and failure cases (e.g., invalid input, not found).
- Cleans up the test database in
setUp
andtearDown
.
16.4 Writing Tests for Controllers
Integration tests verify that controllers, services, and repositories work together correctly.
-
Create
tests/Integration/TaskControllerTest.php
:
<?php
namespace Tests\Integration;
use PHPUnit\Framework\TestCase;
use App\Controllers\v1\TaskController;
use App\Services\TaskService;
use App\Repositories\TaskRepository;
use App\Entities\Task;
class TaskControllerTest extends TestCase {
protected $taskController;
protected $db;
protected function setUp(): void {
$this->db = \App\Config\Database::getInstance();
$this->taskController = new TaskController(new TaskService(new TaskRepository()));
$this->db->exec('INSERT INTO users (id, username, email, password) VALUES (1, "testuser", "test@example.com", "hashed")');
$_SERVER['USER_ID'] = 1; // Simulate authenticated user
}
protected function tearDown(): void {
$this->db->exec('DELETE FROM tasks');
$this->db->exec('DELETE FROM users');
unset($_SERVER['USER_ID']);
}
public function testIndex() {
$this->db->exec('INSERT INTO tasks (user_id, title, status) VALUES (1, "Test Task", "pending")');
ob_start();
$this->taskController->index();
$output = ob_get_clean();
$response = json_decode($output, true);
$this->assertEquals(200, http_response_code());
$this->assertArrayHasKey('data', $response);
$this->assertCount(1, $response['data']);
$this->assertEquals('Test Task', $response['data'][0]['title']);
}
public function testStore() {
$_SERVER['CONTENT_TYPE'] = 'application/json';
$input = json_encode(['title' => 'New Task', 'due_date' => '2025-10-25']);
$GLOBALS['HTTP_RAW_POST_DATA'] = $input; // Simulate JSON input
ob_start();
$this->taskController->store();
$output = ob_get_clean();
$response = json_decode($output, true);
$this->assertEquals(201, http_response_code());
$this->assertArrayHasKey('data', $response);
$this->assertEquals('New Task', $response['data']['title']);
}
}
-
Key Features:
- Uses the real database to test full controller-service-repository interaction.
- Simulates HTTP requests (e.g., JSON input,
USER_ID
) to test controller behavior. - Captures output with
ob_start()
to verify JSON responses.
16.5 Using Postman for Manual and Automated API Testing
Postman is ideal for manual and automated end-to-end testing of API endpoints.
-
Manual Testing with Postman:
- Create a Postman collection for the Task Management API.
- Add requests for key endpoints:
-
POST /v1/auth/login
:{"email": "test@example.com", "password": "secure123"}
-
GET /v1/tasks
: Use the JWT from login in theAuthorization: Bearer <token>
header. -
POST /v1/tasks
:{"title": "Test Task", "due_date": "2025-10-25"}
GET /v1/tasks/search?query=Test
- Verify responses match expected JSON structure and status codes.
-
Automated Testing with Postman:
- Create a Postman test script for
POST /v1/tasks
:
pm.test("Task creation successful", function () { pm.response.to.have.status(201); pm.response.to.be.json; pm.expect(pm.response.json().data).to.have.property('title', 'Test Task'); pm.expect(pm.response.json()).to.have.property('message', 'Task created'); });
- Create a Postman collection runner:
- Add requests to the collection (login, create task, get tasks, search).
- Add test scripts to each request to verify status codes, response structure, and data.
- Run the collection in Postman’s Collection Runner to automate tests.
- Create a Postman test script for
-
Edge Case Testing:
- Test invalid input:
POST /v1/tasks
with{"title": ""}
(expect 400). - Test unauthorized access:
GET /v1/tasks
without a JWT (expect 401). - Test not found:
GET /v1/tasks/999
(expect 404).
- Test invalid input:
Hands-On Activity
Let’s implement and run our test suite!
-
Task 1: Set Up PHPUnit
- Install PHPUnit and Mockery, create
phpunit.xml
andtests/bootstrap.php
. - Create the test database and apply the schema.
- Install PHPUnit and Mockery, create
-
Task 2: Write Unit Tests
- Implement
TaskServiceTest.php
,UserServiceTest.php
, andTaskRepositoryTest.php
. - Run
vendor/bin/phpunit tests/Unit
and verify all tests pass.
- Implement
-
Task 3: Write Integration Tests
- Implement
TaskControllerTest.php
. - Run
vendor/bin/phpunit tests/Integration
and verify controller behavior.
- Implement
-
Task 4: Test with Postman
- Create a Postman collection with requests for all endpoints.
- Add test scripts for
POST /v1/tasks
andGET /v1/tasks
. - Run the collection in Postman’s Collection Runner.
-
Task 5: Update README
- Add a section to
README.md
describing the testing setup, including PHPUnit and Postman instructions. - Include example Postman test scripts and how to run PHPUnit tests.
- Add a section to
Resources
- PHPUnit Documentation: https://phpunit.de/documentation.html
- Mockery Documentation: https://github.com/mockery/mockery
- Postman Testing: https://learning.postman.com/docs/writing-scripts/test-scripts/
- API Testing Best Practices: https://www.softwaretestinghelp.com/api-testing/
- REST API Error Handling: https://restfulapi.net/http-status-codes/ # Module 17: Performance Optimization
Overview
Welcome to Module 17 of Build a Robust RESTful API with PHP 8, from Scratch! Our Task Management API is feature-rich and reliable, but to handle real-world traffic, we need to focus on performance optimization. In this module, we’ll implement caching strategies using Redis, optimize database queries and indexing, apply lazy loading for resource-heavy operations, and use profiling tools to benchmark API performance. By the end, your API will be faster, more scalable, and ready for production-level demands. Let’s dive in and supercharge our API!
Learning Objectives
- Implement caching to reduce server load and improve response times.
- Optimize database queries and indexing for faster data retrieval.
- Use lazy loading to defer resource-intensive operations.
- Profile and benchmark API performance to identify bottlenecks.
- Test performance improvements to ensure scalability.
Content
17.1 Caching Strategies for API Responses
Caching stores frequently accessed data to reduce database queries and improve response times. We’ll use Redis for its speed and simplicity.
-
Why Redis?
- In-memory key-value store, ideal for fast caching.
- Supports expiration for automatic cache invalidation.
- Easy integration with PHP via the
predis
library.
-
Install Redis and Predis:
- Install Redis on your server (e.g.,
sudo apt-get install redis-server
on Ubuntu). - Install the Predis client:
composer require predis/predis
- Install Redis on your server (e.g.,
Configure Redis in
.env
:
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
CACHE_TTL=3600 # Cache time-to-live in seconds (1 hour)
-
Create
src/Cache/CacheManager.php
:
namespace App\Cache;
use Predis\Client;
class CacheManager {
private $redis;
public function __construct() {
$this->redis = new Client([
'scheme' => 'tcp',
'host' => $_ENV['REDIS_HOST'],
'port' => $_ENV['REDIS_PORT']
]);
}
public function get(string $key) {
$data = $this->redis->get($key);
return $data ? json_decode($data, true) : null;
}
public function set(string $key, $value, int $ttl = null) {
$this->redis->set($key, json_encode($value));
if ($ttl) {
$this->redis->expire($key, $ttl);
}
}
public function delete(string $key) {
$this->redis->del([$key]);
}
}
-
Update
TaskService
to Cache Task Listings:
namespace App\Services;
use App\DTO\TaskDTO;
use App\Entities\Task;
use App\Repositories\TaskRepository;
use App\Validation\Validator;
use App\Exceptions\NotFoundException;
use App\Exceptions\ValidationException;
use App\Cache\CacheManager;
class TaskService {
private $taskRepository;
private $cache;
public function __construct(TaskRepository $taskRepository, CacheManager $cache = null) {
$this->taskRepository = $taskRepository;
$this->cache = $cache ?? new CacheManager();
}
public function getTasksByUser(int $userId, ?string $status = null, int $limit = 10, int $page = 1, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$cacheKey = "tasks:user:$userId:status:$status:limit:$limit:page:$page:start:$dueDateStart:end:$dueDateEnd:sort:$sort";
$cached = $this->cache->get($cacheKey);
if ($cached) {
return $cached;
}
$tasks = $this->taskRepository->findByUserId($userId, $status, $dueDateStart, $dueDateEnd, $sort);
$offset = ($page - 1) * $limit;
$total = count($tasks);
$tasks = array_slice($tasks, $offset, $limit);
$result = [
'data' => $tasks,
'meta' => [
'total' => $total,
'page' => $page,
'limit' => $limit,
'total_pages' => ceil($total / $limit)
]
];
$this->cache->set($cacheKey, $result, $_ENV['CACHE_TTL']);
return $result;
}
public function createTask(array $data, int $userId): Task {
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new ValidationException(implode(', ', $errors));
}
if (isset($_FILES['attachment'])) {
$data['attachment'] = FileUploader::upload($_FILES['attachment']);
}
$taskData = array_merge($dto->toArray(), ['user_id' => $userId]);
$task = $this->taskRepository->create($taskData);
$this->cache->delete("tasks:user:$userId:*"); // Invalidate cache
return $task;
}
public function updateTask(int $id, array $data, int $userId): bool {
$dto = new TaskDTO($data);
$errors = Validator::validate($dto);
if ($errors) {
throw new ValidationException(implode(', ', $errors));
}
$task = $this->taskRepository->findById('tasks', $id);
if (!$task || $task->getUserId() !== $userId) {
throw new NotFoundException('Task not found or unauthorized');
}
$result = $this->taskRepository->update($id, $dto->toArray());
$this->cache->delete("tasks:user:$userId:*"); // Invalidate cache
return $result;
}
public function deleteTask(int $id, int $userId): bool {
$task = $this->taskRepository->findById('tasks', $id);
if (!$task || $task->getUserId() !== $userId) {
throw new NotFoundException('Task not found or unauthorized');
}
$result = $this->taskRepository->delete('tasks', $id);
$this->cache->delete("tasks:user:$userId:*"); // Invalidate cache
return $result;
}
// Other methods (e.g., searchTasks) can follow similar caching logic
}
-
Update
TaskController
to Inject CacheManager:
namespace App\Controllers\v1;
use App\Exceptions\ErrorHandler;
use App\Services\TaskService;
use App\Cache\CacheManager;
class TaskController {
private $taskService;
public function __construct(TaskService $taskService) {
$this->taskService = $taskService;
}
// Methods unchanged, ensure TaskService is instantiated with CacheManager in index.php
}
-
Update
index.php
:
require_once __DIR__ . '/../vendor/autoload.php';
use App\Routing\Router;
use App\Controllers\v1\TaskController;
use App\Controllers\v1\UserController;
use App\Controllers\v1\AuthController;
use App\Services\TaskService;
use App\Services\UserService;
use App\Services\AuthService;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
use App\Repositories\CategoryRepository;
use App\Cache\CacheManager;
use App\Middleware\RequestLogger;
use App\Middleware\AuthMiddleware;
$router = new Router();
$cache = new CacheManager();
// Auth Routes
$authController = new AuthController(new AuthService(new UserRepository()));
$router->post('/auth/login', fn() => $authController->login(), [RequestLogger::class]);
$router->post('/auth/refresh', fn() => $authController->refresh(), [RequestLogger::class]);
// Task Routes
$taskController = new TaskController(new TaskService(new TaskRepository(), $cache));
$router->get('/tasks', fn() => $taskController->index(), [RequestLogger::class, AuthMiddleware::class]);
$router->get('/tasks/(\d+)', fn($id) => $taskController->show($id), [RequestLogger::class, AuthMiddleware::class]);
$router->post('/tasks', fn() => $taskController->store(), [RequestLogger::class, AuthMiddleware::class]);
$router->put('/tasks/(\d+)', fn($id) => $taskController->update($id), [RequestLogger::class, AuthMiddleware::class]);
$router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id), [RequestLogger::class, AuthMiddleware::class]);
$router->get('/tasks/search', fn() => $taskController->search(), [RequestLogger::class, AuthMiddleware::class]);
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/users', fn() => $userController->store(), [RequestLogger::class]);
$router->get('/users/(\d+)', fn($id) => $userController->show($id), [RequestLogger::class, AuthMiddleware::class]);
$router->dispatch();
-
Key Features:
- Caches task listings with a unique key based on query parameters.
- Invalidates cache on task creation, update, or deletion to ensure fresh data.
- Uses
CACHE_TTL
from.env
for cache expiration.
17.2 Optimizing Database Queries and Indexing
Optimizing queries and indexes reduces database load and speeds up responses.
-
Query Optimization:
- Minimize
SELECT *
by specifying only needed columns. - Use joins efficiently and avoid unnecessary subqueries.
- Minimize
-
Update
TaskRepository::findByUserId
:
public function findByUserId(int $userId, ?string $status = null, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$query = 'SELECT t.id, t.user_id, t.title, t.description, t.due_date, t.status, t.attachment, t.priority, t.created_at, t.updated_at,
GROUP_CONCAT(c.id) as category_ids, GROUP_CONCAT(c.name) as category_names
FROM tasks t
LEFT JOIN task_category tc ON t.id = tc.task_id
LEFT JOIN categories c ON tc.category_id = c.id
WHERE t.user_id = :user_id';
$params = ['user_id' => $userId];
if ($status) {
$query .= ' AND t.status = :status';
$params['status'] = $status;
}
if ($dueDateStart) {
$query .= ' AND t.due_date >= :due_date_start';
$params['due_date_start'] = $dueDateStart;
}
if ($dueDateEnd) {
$query .= ' AND t.due_date <= :due_date_end';
$params['due_date_end'] = $dueDateEnd;
}
if ($sort) {
[$field, $direction] = explode(':', $sort);
$field = in_array($field, ['title', 'due_date', 'status', 'priority']) ? $field : 'created_at';
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$query .= " ORDER BY t.$field $direction";
}
$query .= ' GROUP BY t.id';
$stmt = $this->db->prepare($query);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(function ($row) {
$row['categories'] = $row['category_ids'] ? array_map(
fn($id, $name) => new Category(['id' => $id, 'name' => $name]),
explode(',', $row['category_ids']),
explode(',', $row['category_names'])
) : [];
unset($row['category_ids'], $row['category_names']);
return new Task($row);
}, $rows);
}
-
Indexing Review (from Module 13):
- Ensure indexes exist on frequently queried columns:
CREATE INDEX idx_user_id ON tasks(user_id); CREATE INDEX idx_status ON tasks(status); CREATE INDEX idx_due_date ON tasks(due_date); CREATE INDEX idx_priority ON tasks(priority); ALTER TABLE tasks ADD FULLTEXT(title, description);
-
Optimization Tips:
- Use
EXPLAIN
to analyze query performance:
EXPLAIN SELECT * FROM tasks WHERE user_id = 1 AND status = 'pending';
- Ensure indexes cover common query conditions (
user_id
,status
,due_date
). - Avoid over-indexing to minimize write performance impact.
- Use
17.3 Implementing Lazy Loading for Resources
Lazy loading defers loading of resource-intensive data (e.g., task categories) until needed.
-
Update
Task.php
to Support Lazy Loading Categories:
namespace App\Entities;
use App\Repositories\CategoryRepository;
class Task {
private $id;
private $userId;
private $title;
private $description;
private $dueDate;
private $status;
private $attachment;
private $priority;
private $createdAt;
private $updatedAt;
private $categories;
private $categoryRepository;
public function __construct(array $data = [], CategoryRepository $categoryRepository = null) {
$this->id = $data['id'] ?? null;
$this->userId = $data['user_id'] ?? null;
$this->title = $data['title'] ?? null;
$this->description = $data['description'] ?? null;
$this->dueDate = $data['due_date'] ?? null;
$this->status = $data['status'] ?? 'pending';
$this->attachment = $data['attachment'] ?? null;
$this->priority = $data['priority'] ?? 'medium';
$this->createdAt = $data['created_at'] ?? null;
$this->updatedAt = $data['updated_at'] ?? null;
$this->categories = $data['categories'] ?? null; // null indicates not loaded
$this->categoryRepository = $categoryRepository;
}
public function getCategories() {
if ($this->categories === null && $this->categoryRepository) {
$this->categories = $this->categoryRepository->getCategoriesForTask($this->id);
}
return $this->categories ?? [];
}
// Other getters/setters and toArray unchanged
}
-
Update
TaskRepository::findById
to Skip Categories Initially:
public function findById(string $table, $id): ?Task {
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE id = :id');
$stmt->execute(['id' => $id]);
$data = $stmt->fetch();
return $data ? new Task($data, new CategoryRepository()) : null;
}
-
Update
TaskRepository::findByUserId
to Skip Categories:
public function findByUserId(int $userId, ?string $status = null, ?string $dueDateStart = null, ?string $dueDateEnd = null, ?string $sort = null): array {
$query = 'SELECT id, user_id, title, description, due_date, status, attachment, priority, created_at, updated_at
FROM tasks WHERE user_id = :user_id';
$params = ['user_id' => $userId];
if ($status) {
$query .= ' AND status = :status';
$params['status'] = $status;
}
if ($dueDateStart) {
$query .= ' AND due_date >= :due_date_start';
$params['due_date_start'] = $dueDateStart;
}
if ($dueDateEnd) {
$query .= ' AND due_date <= :due_date_end';
$params['due_date_end'] = $dueDateEnd;
}
if ($sort) {
[$field, $direction] = explode(':', $sort);
$field = in_array($field, ['title', 'due_date', 'status', 'priority']) ? $field : 'created_at';
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$query .= " ORDER BY $field $direction";
}
$stmt = $this->db->prepare($query);
$stmt->execute($params);
$rows = $stmt->fetchAll();
return array_map(fn($row) => new Task($row, new CategoryRepository()), $rows);
}
-
Why Lazy Loading?
- Defers category queries until
getCategories()
is called. - Reduces database load for endpoints that don’t need category data.
- Improves performance for large task lists.
- Defers category queries until
17.4 Profiling and Benchmarking API Performance
Profiling identifies bottlenecks, and benchmarking measures performance improvements.
-
Install Xdebug for Profiling:
- Install Xdebug (
sudo apt-get install php-xdebug
on Ubuntu) and enable inphp.ini
:
[xdebug] xdebug.mode=profile xdebug.output_dir=/tmp
- Install Xdebug (
-
Profile with Xdebug:
- Enable profiling for a request (e.g.,
GET /v1/tasks
withXDEBUG_PROFILE=1
in the query string). - Analyze the cachegrind file using a tool like Webgrind or KCachegrind.
- Enable profiling for a request (e.g.,
-
Benchmark with Apache Bench (ab):
- Install
ab
:
sudo apt-get install apache2-utils
- Run a benchmark:
ab -n 100 -c 10 -H "Authorization: Bearer <your-jwt-token>" http://localhost:8000/v1/tasks
- Interpret results (requests per second, time per request).
- Install
Create a Benchmark Script (
tests/benchmark.php
):
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Services\TaskService;
use App\Repositories\TaskRepository;
use App\Cache\CacheManager;
$iterations = 100;
$start = microtime(true);
$taskService = new TaskService(new TaskRepository(), new CacheManager());
for ($i = 0; $i < $iterations; $i++) {
$taskService->getTasksByUser(1);
}
$end = microtime(true);
$time = $end - $start;
echo "Executed $iterations requests in $time seconds\n";
echo "Average time per request: " . ($time / $iterations) . " seconds\n";
- Run:
php tests/benchmark.php
- Key Metrics to Monitor:
- Response time for
GET /v1/tasks
with and without caching. - Database query execution time (use
EXPLAIN
or PDO profiling). - Cache hit/miss ratio in Redis (
redis-cli MONITOR
).
Hands-On Activity
Let’s optimize and test our API’s performance!
-
Task 1: Implement Caching
- Install Redis and Predis, create
CacheManager.php
. - Update
TaskService
andindex.php
to use caching. - Test
GET /v1/tasks
with Postman and verify cache hits in Redis (redis-cli MONITOR
).
- Install Redis and Predis, create
-
Task 2: Optimize Database Queries
- Update
TaskRepository::findByUserId
to select specific columns. - Run
EXPLAIN
on the query to confirm index usage. - Test
GET /v1/tasks?status=pending
and verify faster response times.
- Update
-
Task 3: Implement Lazy Loading
- Update
Task.php
andTaskRepository
for lazy loading categories. - Test
GET /v1/tasks
and confirm categories are only loaded when accessed.
- Update
-
Task 4: Profile and Benchmark
- Install Xdebug and profile
GET /v1/tasks
with and without caching. - Run
ab
to benchmarkGET /v1/tasks
before and after optimizations. - Compare results and document improvements.
- Install Xdebug and profile
-
Task 5: Update README
- Add a section to
README.md
describing caching, query optimization, lazy loading, and profiling. - Include instructions for setting up Redis and running benchmarks.
- Add a section to
Resources
- Redis Documentation: https://redis.io/docs/
- Predis Library: https://github.com/predis/predis
- MySQL Query Optimization: https://dev.mysql.com/doc/refman/8.0/en/optimization.html
- Xdebug Profiling: https://xdebug.org/docs/profiler
- Apache Bench: https://httpd.apache.org/docs/2.4/programs/ab.html
Module 18: Security Best Practices
Overview
Welcome to Module 18 of Build a Robust RESTful API with PHP 8, from Scratch! Security is paramount for our Task Management API to protect user data and maintain trust. In this module, we’ll fortify the API against common vulnerabilities like SQL injection, XSS, and CSRF, implement rate limiting and throttling to prevent abuse, enforce HTTPS and secure headers, and establish auditing and monitoring to detect security issues. By the end, your API will be robust, secure, and production-ready. Let’s lock it down!
Learning Objectives
- Protect the API from SQL injection, XSS, and CSRF vulnerabilities.
- Implement rate limiting to mitigate abuse and denial-of-service attacks.
- Configure HTTPS and secure HTTP headers for safe data transmission.
- Set up auditing and monitoring to track and respond to security events.
- Test security measures to ensure comprehensive protection.
Content
18.1 Securing APIs Against Common Vulnerabilities
We’ll address three major vulnerabilities to ensure the API is secure.
-
SQL Injection:
-
Prevention: Use PDO prepared statements to safely handle user inputs, already implemented in
TaskRepository
andUserRepository
(Modules 13, 14). -
Example (from
TaskRepository
):
public function create(array $data): Task { $this->db->beginTransaction(); try { $stmt = $this->db->prepare( 'INSERT INTO tasks (user_id, title, description, due_date, status, attachment, priority) VALUES (:user_id, :title, :description, :due_date, :status, :attachment, :priority)' ); $stmt->execute([ 'user_id' => $data['user_id'], 'title' => $data['title'], 'description' => $data['description'] ?? null, 'due_date' => $data['due_date'] ?? null, 'status' => $data['status'] ?? 'pending', 'attachment' => $data['attachment'] ?? null, 'priority' => $data['priority'] ?? 'medium' ]); $id = $this->db->lastInsertId(); $this->db->commit(); return $this->findById('tasks', $id); } catch (\PDOException $e) { $this->db->rollBack(); throw new \RuntimeException('Failed to create task: ' . $e->getMessage()); } }
- Why Secure? Prepared statements bind parameters, preventing malicious SQL code execution.
-
Prevention: Use PDO prepared statements to safely handle user inputs, already implemented in
-
Cross-Site Scripting (XSS):
- Prevention: Since the API returns JSON, XSS risks are low, but we’ll ensure inputs are sanitized to prevent storing harmful data (Module 11).
-
Enhance
TaskService
Sanitization:
public function createTask(array $data, int $userId): Task { $data['title'] = filter_var($data['title'] ?? '', FILTER_SANITIZE_SPECIAL_CHARS, FILTER_FLAG_NO_ENCODE_QUOTES); $data['description'] = filter_var($data['description'] ?? '', FILTER_SANITIZE_SPECIAL_CHARS, FILTER_FLAG_NO_ENCODE_QUOTES); $dto = new TaskDTO($data); $errors = Validator::validate($dto); if ($errors) { throw new \App\Exceptions\ValidationException(implode(', ', $errors)); } if (isset($_FILES['attachment'])) { $data['attachment'] = FileUploader::upload($_FILES['attachment']); } $taskData = array_merge($dto->toArray(), ['user_id' => $userId]); return $this->taskRepository->create($taskData); }
-
Why?
FILTER_SANITIZE_SPECIAL_CHARS
removes or escapes HTML/JS code, ensuring safe storage.
-
Cross-Site Request Forgery (CSRF):
-
Prevention: Our JWT-based authentication (Module 10) requires a valid
Authorization: Bearer
token, making CSRF attacks unlikely for stateless API requests. -
Enhancement: Ensure short-lived JWTs by setting
JWT_EXPIRY=3600
(1 hour) in.env
and encouraging token refresh.
-
Prevention: Our JWT-based authentication (Module 10) requires a valid
18.2 Implementing Rate Limiting and Throttling
Rate limiting restricts the number of requests per client to prevent abuse and ensure fair usage.
-
Create
src/Middleware/RateLimitMiddleware.php
:
namespace App\Middleware;
use App\Cache\CacheManager;
class RateLimitMiddleware {
private $cache;
private $limit = 100; // Requests per minute
private $window = 60; // 1-minute window
public function __construct(CacheManager $cache) {
$this->cache = $cache;
}
public function handle(callable $next) {
$key = 'rate_limit:' . ($_SERVER['USER_ID'] ?? $_SERVER['REMOTE_ADDR']);
$requests = $this->cache->get($key) ?: ['count' => 0, 'start' => time()];
if (time() - $requests['start'] > $this->window) {
$requests = ['count' => 0, 'start' => time()];
}
if ($requests['count'] >= $this->limit) {
http_response_code(429);
header('Content-Type: application/json');
header('Retry-After: ' . ($this->window - (time() - $requests['start'])));
echo json_encode(['error' => 'Rate limit exceeded. Try again later.']);
return;
}
$requests['count']++;
$this->cache->set($key, $requests, $this->window);
return $next();
}
}
-
Update
index.php
to Apply Rate Limiting:
require_once __DIR__ . '/../vendor/autoload.php';
use App\Routing\Router;
use App\Controllers\v1\TaskController;
use App\Controllers\v1\UserController;
use App\Controllers\v1\AuthController;
use App\Services\TaskService;
use App\Services\UserService;
use App\Services\AuthService;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
use App\Repositories\CategoryRepository;
use App\Cache\CacheManager;
use App\Middleware\RequestLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RateLimitMiddleware;
$router = new Router();
$cache = new CacheManager();
// Auth Routes
$authController = new AuthController(new AuthService(new UserRepository()));
$router->post('/auth/login', fn() => $authController->login(), [RequestLogger::class, RateLimitMiddleware::class]);
$router->post('/auth/refresh', fn() => $authController->refresh(), [RequestLogger::class, RateLimitMiddleware::class]);
// Task Routes
$taskController = new TaskController(new TaskService(new TaskRepository(), $cache));
$router->get('/tasks', fn() => $taskController->index(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
$router->get('/tasks/(\d+)', fn($id) => $taskController->show($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
$router->post('/tasks', fn() => $taskController->store(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
$router->put('/tasks/(\d+)', fn($id) => $taskController->update($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
$router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
$router->get('/tasks/search', fn() => $taskController->search(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
// User Routes
$userController = new UserController(new UserService(new UserRepository()));
$router->post('/users', fn() => $userController->store(), [RequestLogger::class, RateLimitMiddleware::class]);
$router->get('/users/(\d+)', fn($id) => $userController->show($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class]);
$router->dispatch();
-
Enhancements:
- Uses
USER_ID
for authenticated users, falling back toREMOTE_ADDR
for unauthenticated requests. - Adds
Retry-After
header to inform clients when to retry. - Limits to 100 requests per minute, adjustable via configuration.
- Uses
18.3 Using HTTPS and Secure Headers
HTTPS encrypts data in transit, and secure headers protect against common attacks.
-
Enforce HTTPS:
- Configure your web server to redirect HTTP to HTTPS.
-
Apache Example (
.htaccess
inpublic/
):
<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{HTTPS} off RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] </IfModule>
- Obtain an SSL certificate (e.g., via Let’s Encrypt with Certbot).
- Test HTTPS Setup:
curl -I http://localhost:8000/v1/tasks
- Expect a 301 redirect to
https://localhost:8000/v1/tasks
.
-
Add Secure Headers:
- Create
src/Middleware/SecurityHeadersMiddleware.php
:
namespace App\Middleware; class SecurityHeadersMiddleware { public function handle(callable $next) { header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('Content-Security-Policy: default-src \'self\'; script-src \'none\''); header('Referrer-Policy: no-referrer'); header('X-XSS-Protection: 1; mode=block'); return $next(); } }
-
Update
index.php
to Apply Headers:
use App\Middleware\SecurityHeadersMiddleware; $router->post('/auth/login', fn() => $authController->login(), [RequestLogger::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->post('/auth/refresh', fn() => $authController->refresh(), [RequestLogger::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->get('/tasks', fn() => $taskController->index(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->get('/tasks/(\d+)', fn($id) => $taskController->show($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->post('/tasks', fn() => $taskController->store(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->put('/tasks/(\d+)', fn($id) => $taskController->update($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->delete('/tasks/(\d+)', fn($id) => $taskController->destroy($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->get('/tasks/search', fn() => $taskController->search(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->post('/users', fn() => $userController->store(), [RequestLogger::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]); $router->get('/users/(\d+)', fn($id) => $userController->show($id), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]);
- Create
-
Headers Explained:
-
Strict-Transport-Security
: Enforces HTTPS for 1 year, including subdomains. -
X-Content-Type-Options: nosniff
: Prevents MIME-type sniffing. -
X-Frame-Options: DENY
: Blocks framing to prevent clickjacking. -
Content-Security-Policy
: Restricts resources toself
, disables scripts. -
Referrer-Policy: no-referrer
: Prevents sending referrer information. -
X-XSS-Protection
: Enables browser XSS filtering (legacy, but added for compatibility).
-
18.4 Auditing and Monitoring API Security
Auditing logs security events, and monitoring ensures timely detection of issues.
-
Enhance
RequestLogger
Middleware:
namespace App\Middleware;
use App\Security\AuditLogger;
class RequestLogger {
public function handle(callable $next) {
$log = sprintf(
"[%s] %s %s from %s with headers %s\n",
date('Y-m-d H:i:s'),
$_SERVER['REQUEST_METHOD'],
$_SERVER['REQUEST_URI'],
$_SERVER['REMOTE_ADDR'],
json_encode(getallheaders())
);
file_put_contents(__DIR__ . '/../../logs/requests.log', $log, FILE_APPEND);
AuditLogger::logSecurityEvent('Request Received', [
'method' => $_SERVER['REQUEST_METHOD'],
'uri' => $_SERVER['REQUEST_URI'],
'ip' => $_SERVER['REMOTE_ADDR'],
'user_id' => $_SERVER['USER_ID'] ?? 'unauthenticated'
]);
return $next();
}
}
-
Create
src/Security/AuditLogger.php
:
namespace App\Security;
class AuditLogger {
public static function logSecurityEvent(string $event, array $details = []) {
$log = sprintf(
"[%s] Security Event: %s | Details: %s\n",
date('Y-m-d H:i:s'),
$event,
json_encode($details)
);
file_put_contents(__DIR__ . '/../../logs/security.log', $log, FILE_APPEND);
}
}
-
Update
RateLimitMiddleware
to Log Exceeded Limits:
public function handle(callable $next) {
$key = 'rate_limit:' . ($_SERVER['USER_ID'] ?? $_SERVER['REMOTE_ADDR']);
$requests = $this->cache->get($key) ?: ['count' => 0, 'start' => time()];
if (time() - $requests['start'] > $this->window) {
$requests = ['count' => 0, 'start' => time()];
}
if ($requests['count'] >= $this->limit) {
\App\Security\AuditLogger::logSecurityEvent('Rate Limit Exceeded', [
'ip' => $_SERVER['REMOTE_ADDR'],
'user_id' => $_SERVER['USER_ID'] ?? 'unauthenticated',
'uri' => $_SERVER['REQUEST_URI']
]);
http_response_code(429);
header('Content-Type: application/json');
header('Retry-After: ' . ($this->window - (time() - $requests['start'])));
echo json_encode(['error' => 'Rate limit exceeded. Try again later.']);
return;
}
$requests['count']++;
$this->cache->set($key, $requests, $this->window);
return $next();
}
-
Monitoring Setup:
- Create a log directory (
logs/
) with write permissions (chmod 775 logs
). - Review
logs/security.log
andlogs/requests.log
for suspicious activity. - Example
security.log
entry:
[2025-10-12 17:21:00] Security Event: Rate Limit Exceeded | Details: {"ip":"127.0.0.1","user_id":"unauthenticated","uri":"/v1/tasks"}
- For production, consider integrating with a monitoring tool like Prometheus or ELK Stack for real-time alerts.
- Create a log directory (
Hands-On Activity
Let’s secure and test our API’s defenses!
-
Task 1: Test SQL Injection Protection
- Send
POST /v1/tasks
with malicious input:{"title": "test'; DROP TABLE tasks; --"}
. - Verify the request fails safely with a 400 error due to validation, not SQL execution.
- Send
-
Task 2: Implement and Test Rate Limiting
- Create
RateLimitMiddleware.php
and apply it to all routes. - Use Apache Bench to simulate rapid requests:
ab -n 101 -c 10 -H "Authorization: Bearer <your-jwt-token>" https://localhost:8000/v1/tasks
- Confirm the 101st request returns a 429 error with
Retry-After
header. - Check
logs/security.log
for rate limit violation logs.
- Create
-
Task 3: Set Up HTTPS and Secure Headers
- Configure Apache or Nginx for HTTPS using a Let’s Encrypt certificate.
- Test with
curl -I https://localhost:8000/v1/tasks
to verify secure headers. - Ensure HTTP requests redirect to HTTPS.
-
Task 4: Test Auditing and Monitoring
- Send an invalid JWT to
GET /v1/tasks
and verify the event is logged insecurity.log
. - Trigger a rate limit violation and confirm logging in
security.log
. - Review
requests.log
to ensure all requests are logged with headers and user IDs.
- Send an invalid JWT to
-
Task 5: Update README
- Add a section to
README.md
covering: - SQL injection, XSS, and CSRF protections.
- Rate limiting setup and configuration.
- HTTPS and secure headers implementation.
- Auditing and monitoring with log file locations.
- Include example
curl
commands to test security features.
- Add a section to
Resources
- OWASP API Security Top 10: https://owasp.org/www-project-api-security/
- PHP PDO Prepared Statements: https://www.php.net/manual/en/pdo.prepared-statements.php
- Let’s Encrypt Setup: https://letsencrypt.org/getting-started/
- Secure HTTP Headers: https://owasp.org/www-project-secure-headers/
- Redis Rate Limiting: https://redis.io/docs/manual/patterns/ratelimiting/ # Module 19: Deploying the API
Overview
Welcome to Module 19 of Build a Robust RESTful API with PHP 8, from Scratch! Our Task Management API is now feature-rich, secure, and optimized. It’s time to take it to production! In this module, we’ll focus on preparing the API for production, configuring a production server using Nginx, setting up CI/CD pipelines for automated deployment, and implementing monitoring and scaling strategies to ensure reliability and performance in production. By the end, your API will be deployed, automated, and ready to handle real-world traffic. Let’s get our API live!
Learning Objectives
- Prepare the API for production with environment-specific configurations.
- Configure a production server (Nginx) for secure and efficient hosting.
- Set up a CI/CD pipeline for automated testing and deployment.
- Implement monitoring and scaling strategies for production reliability.
- Test the deployed API to ensure functionality and performance.
Content
19.1 Preparing the API for Production
Preparing the API involves optimizing configurations, securing sensitive data, and ensuring production-readiness.
-
Environment Configuration:
- Update
.env
for production:
APP_ENV=production DB_HOST=production-db-host DB_NAME=task_management DB_USER=prod_user DB_PASS=secure_password JWT_SECRET=your-secure-jwt-secret JWT_EXPIRY=3600 REDIS_HOST=production-redis-host REDIS_PORT=6379 CACHE_TTL=3600
- Create a separate
.env.production
file and load it in production.
- Update
-
Secure Database Credentials:
- Store
DB_USER
andDB_PASS
in a secure vault (e.g., AWS Secrets Manager) or restricted file. - Restrict database access to the production server’s IP.
- Store
-
Optimize PHP Settings:
- Update
php.ini
for production:
opcache.enable=1 opcache.memory_consumption=128 opcache.interned_strings_buffer=8 opcache.max_accelerated_files=10000 error_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICT display_errors=Off log_errors=On error_log=/var/log/php_errors.log
- Enable OPcache to cache compiled PHP code for faster execution.
- Update
-
Secure File Uploads:
- Restrict upload directory permissions (
chmod 750 uploads
). - Validate file types and sizes in
FileUploader.php
(Module 14).
- Restrict upload directory permissions (
-
Logging Configuration:
- Ensure
logs/
directory is writable (chmod 775 logs
). - Rotate logs to prevent disk space issues:
sudo nano /etc/logrotate.d/task-management-api
- Ensure
```logrotate
/path/to/task-management-api/logs/*.log {
daily
rotate 7
compress
missingok
notifempty
create 0640 www-data www-data
}
```
19.2 Configuring a Production Server (Nginx)
Nginx is a lightweight, high-performance web server ideal for hosting PHP APIs.
-
Install Nginx and PHP-FPM:
- On Ubuntu:
sudo apt-get update sudo apt-get install nginx php8.1-fpm php8.1-mysql php8.1-redis
-
Configure Nginx:
- Create
/etc/nginx/sites-available/task-management-api
:
server { listen 80; server_name api.yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name api.yourdomain.com; ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem; root /var/www/task-management-api/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~ /\. { deny all; } access_log /var/log/nginx/task-management-api_access.log; error_log /var/log/nginx/task-management-api_error.log; }
- Enable the configuration:
sudo ln -s /etc/nginx/sites-available/task-management-api /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
- Create
-
Secure SSL with Let’s Encrypt:
- Install Certbot:
sudo apt-get install certbot python3-certbot-nginx
- Obtain a certificate:
sudo certbot --nginx -d api.yourdomain.com
-
Directory Permissions:
- Set ownership for the API directory:
sudo chown -R www-data:www-data /var/www/task-management-api sudo chmod -R 755 /var/www/task-management-api
-
Key Features:
- Redirects HTTP to HTTPS for security.
- Serves PHP via PHP-FPM for performance.
- Denies access to hidden files (e.g.,
.env
). - Logs access and errors for monitoring.
19.3 Setting Up CI/CD Pipelines for Deployment
A CI/CD pipeline automates testing and deployment, ensuring reliable releases.
-
Choose a CI/CD Tool:
- We’ll use GitHub Actions for its integration with GitHub repositories.
-
Create
.github/workflows/deploy.yml
:
name: CI/CD Pipeline
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: task_management_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: pdo_mysql, redis
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPUnit tests
env:
DB_HOST: 127.0.0.1
DB_NAME: task_management_test
DB_USER: root
DB_PASS: root
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
run: vendor/bin/phpunit
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to production
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
run: |
echo "$SSH_PRIVATE_KEY" > private_key
chmod 600 private_key
ssh -o StrictHostKeyChecking=no -i private_key $SERVER_USER@$SERVER_HOST << 'EOF'
cd /var/www/task-management-api
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan cache:clear
sudo systemctl reload nginx
EOF
-
Configure GitHub Secrets:
- Add
SSH_PRIVATE_KEY
,SERVER_HOST
, andSERVER_USER
in your GitHub repository’s Settings > Secrets and variables > Actions.
- Add
-
Key Features:
-
CI: Runs PHPUnit tests on every push to
main
. - CD: Deploys to the production server via SSH, updating code and clearing caches.
- Requires a MySQL and Redis service for testing.
-
CI: Runs PHPUnit tests on every push to
19.4 Monitoring and Scaling the API in Production
Monitoring ensures the API remains healthy, and scaling handles increased traffic.
-
Monitoring Setup:
-
Update
ErrorHandler
for Production Logging:
namespace App\Exceptions; use Throwable; class ErrorHandler { public static function handle(Throwable $exception): void { $statusCode = 500; $errorMessage = 'Internal Server Error'; switch (get_class($exception)) { case \InvalidArgumentException::class: case \App\Exceptions\ValidationException::class: $statusCode = 400; $errorMessage = $exception->getMessage(); break; case \RuntimeException::class: $statusCode = 401; $errorMessage = $exception->getMessage(); break; case \App\Exceptions\NotFoundException::class: $statusCode = 404; $errorMessage = $exception->getMessage(); break; } self::logError($exception); http_response_code($statusCode); header('Content-Type: application/json'); echo json_encode(['error' => $errorMessage]); exit; } private static function logError(Throwable $exception): void { $logMessage = sprintf( "[%s] %s: %s in %s:%d\nStack trace: %s\n", date('Y-m-d H:i:s'), get_class($exception), $exception->getMessage(), $exception->getFile(), $exception->getLine(), $exception->getTraceAsString() ); file_put_contents( __DIR__ . '/../../logs/errors.log', $logMessage, FILE_APPEND ); // In production, send to monitoring service (e.g., Sentry) if ($_ENV['APP_ENV'] === 'production') { // Example: Send to Sentry (requires sentry/sentry PHP package) // \Sentry\captureException($exception); } } }
- Install Sentry for error tracking:
composer require sentry/sentry
- Configure Sentry in
bootstrap.php
or a similar entry point:
if ($_ENV['APP_ENV'] === 'production') { \Sentry\init(['dsn' => 'your-sentry-dsn']); }
- Monitor logs: Check
/var/log/nginx/task-management-api_error.log
andlogs/errors.log
.
-
Update
-
Scaling Strategies:
- Horizontal Scaling: Add more Nginx/PHP-FPM servers behind a load balancer (e.g., AWS ELB).
-
Database Scaling: Use read replicas for
task_management
database to offload read queries. - Redis Scaling: Use Redis Cluster for high availability and load distribution.
-
PHP-FPM Tuning: Adjust
pm.max_children
in/etc/php/8.1/fpm/pool.d/www.conf
based on server resources:
pm = dynamic pm.max_children = 50 pm.start_servers = 5 pm.min_spare_servers = 5 pm.max_spare_servers = 10
-
Cache Optimization: Increase
CACHE_TTL
for less frequent updates or use Redis persistence.
-
Health Check Endpoint:
- Create
src/Controllers/v1/HealthController.php
:
namespace App\Controllers\v1; use App\Config\Database; class HealthController { public function check() { try { $db = Database::getInstance(); $db->query('SELECT 1'); http_response_code(200); header('Content-Type: application/json'); echo json_encode(['status' => 'healthy', 'database' => 'connected']); } catch (\Exception $e) { http_response_code(503); header('Content-Type: application/json'); echo json_encode(['status' => 'unhealthy', 'error' => $e->getMessage()]); } } }
- Add to
index.php
:
use App\Controllers\v1\HealthController; $healthController = new HealthController(); $router->get('/health', fn() => $healthController->check(), [RequestLogger::class]);
- Create
Hands-On Activity
Let’s deploy and monitor our API!
-
Task 1: Prepare for Production
- Create
.env.production
and updatephp.ini
for production settings. - Set up log rotation and verify
logs/
is writable. - Test locally with
APP_ENV=production
to ensure no errors.
- Create
-
Task 2: Configure Nginx
- Set up Nginx with the provided configuration.
- Obtain an SSL certificate with Certbot and verify HTTPS redirects.
- Test
curl -I https://api.yourdomain.com/v1/tasks
to confirm headers and SSL.
-
Task 3: Set Up CI/CD
- Create a GitHub repository and push the project.
- Configure
.github/workflows/deploy.yml
and add GitHub Secrets. - Push a change to
main
and verify tests and deployment succeed.
-
Task 4: Monitor and Scale
- Set up Sentry and send a test error (e.g., trigger a 404 via
GET /v1/tasks/999
). - Test the
/v1/health
endpoint withcurl https://api.yourdomain.com/v1/health
. - Simulate load with Apache Bench:
ab -n 1000 -c 50 -H "Authorization: Bearer <your-jwt-token>" https://api.yourdomain.com/v1/tasks
- Analyze performance and adjust PHP-FPM settings if needed.
- Set up Sentry and send a test error (e.g., trigger a 404 via
-
Task 5: Update README
- Add a section to
README.md
covering: - Production preparation steps (
.env
,php.ini
). - Nginx configuration and SSL setup.
- CI/CD pipeline setup with GitHub Actions.
- Monitoring with Sentry and health checks.
- Scaling strategies for high traffic.
- Include example commands for deployment and monitoring.
- Add a section to
Resources
- Nginx Configuration: https://nginx.org/en/docs/
- Let’s Encrypt with Certbot: https://certbot.eff.org/
- GitHub Actions: https://docs.github.com/en/actions
- Sentry Error Tracking: https://docs.sentry.io/
- PHP-FPM Tuning: https://www.php.net/manual/en/install.fpm.configuration.php # Module 20: Final Project and Wrap-Up
Overview
Welcome to Module 20, the final module of Build a Robust RESTful API with PHP 8, from Scratch! In this module, we’ll bring everything together by building a complete Task Management API, integrating all layers (data, business, presentation), and documenting it with OpenAPI/Swagger. We’ll also recap the course, highlight best practices, and outline next steps for advancing your API development skills. By the end, you’ll have a production-ready API, comprehensive documentation, and a clear path forward. Let’s wrap up this journey with a fully functional API!
Learning Objectives
- Build a complete Task Management API, integrating all components from previous modules.
- Ensure seamless interaction between the Data Access, Business Logic, and Presentation Layers.
- Document the API using OpenAPI/Swagger for clarity and usability.
- Recap key concepts and best practices from the course.
- Identify next steps for advancing API development skills.
Content
20.1 Building a Complete RESTful API
We’ve developed the Task Management API across previous modules. Here, we’ll ensure all components are integrated and fully functional. The API supports user authentication, task CRUD operations, categories, file uploads, pagination, search, and security features.
-
Review Existing Features (from Modules 1–19):
-
Data Access Layer (Modules 8, 13): Uses PDO with
TaskRepository
,UserRepository
, andCategoryRepository
for database operations, transactions, and relationships. -
Business Logic Layer (Modules 9–11, 14): Implements
TaskService
,UserService
, andAuthService
with validation, sanitization, and caching. -
Presentation Layer (Modules 7, 12, 15): Handles routing, controllers (
TaskController
,UserController
,AuthController
), and middleware (AuthMiddleware
,RateLimitMiddleware
,SecurityHeadersMiddleware
). - Advanced Features (Modules 14, 17): Pagination, filtering, sorting, file uploads, full-text search, and caching with Redis.
- Security (Module 18): Protects against SQL injection, XSS, CSRF, with rate limiting and HTTPS.
- Testing and Deployment (Modules 16, 19): Unit/integration tests with PHPUnit, Postman tests, and CI/CD with GitHub Actions.
-
Data Access Layer (Modules 8, 13): Uses PDO with
-
Final Enhancements:
- Add a new endpoint to assign categories to tasks.
- Create
src/Controllers/v1/CategoryController.php
:
namespace App\Controllers\v1; use App\Exceptions\ErrorHandler; use App\Services\CategoryService; use App\Repositories\CategoryRepository; class CategoryController { private $categoryService; public function __construct(CategoryService $categoryService) { $this->categoryService = $categoryService; } public function assignToTask() { try { $data = $this->getRequestData(); $taskId = $data['task_id'] ?? throw new \App\Exceptions\ValidationException('Task ID is required'); $categoryId = $data['category_id'] ?? throw new \App\Exceptions\ValidationException('Category ID is required'); $userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated'); $this->categoryService->assignCategoryToTask($taskId, $categoryId, $userId); $this->sendResponse(200, ['message' => 'Category assigned to task']); } catch (\Throwable $e) { ErrorHandler::handle($e); } } private function getRequestData(): array { $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; if (strpos($contentType, 'application/json') !== false) { $input = file_get_contents('php://input'); return json_decode($input, true) ?? []; } return []; } private function sendResponse(int $status, array $data) { http_response_code($status); header('Content-Type: application/json'); echo json_encode($data); } }
- Create
src/Services/CategoryService.php
:
namespace App\Services; use App\Repositories\CategoryRepository; use App\Repositories\TaskRepository; use App\Exceptions\NotFoundException; class CategoryService { private $categoryRepository; private $taskRepository; public function __construct(CategoryRepository $categoryRepository, TaskRepository $taskRepository) { $this->categoryRepository = $categoryRepository; $this->taskRepository = $taskRepository; } public function assignCategoryToTask(int $taskId, int $categoryId, int $userId): bool { $task = $this->taskRepository->findById('tasks', $taskId); if (!$task || $task->getUserId() !== $userId) { throw new NotFoundException('Task not found or unauthorized'); } $category = $this->categoryRepository->findById('categories', $categoryId); if (!$category) { throw new NotFoundException('Category not found'); } return $this->categoryRepository->assignCategoryToTask($taskId, $categoryId); } }
- Update
index.php
:
use App\Controllers\v1\CategoryController; use App\Services\CategoryService; $categoryController = new CategoryController(new CategoryService(new CategoryRepository(), new TaskRepository())); $router->post('/categories/assign', fn() => $categoryController->assignToTask(), [RequestLogger::class, AuthMiddleware::class, RateLimitMiddleware::class, SecurityHeadersMiddleware::class]);
-
Integration Check:
- Ensure all layers work together:
-
Data Layer:
TaskRepository
handles CRUD and relationships with categories. -
Business Layer:
TaskService
validates and sanitizes inputs, uses caching, and invalidates cache on updates. -
Presentation Layer:
TaskController
routes requests, applies middleware, and returns JSON responses. - Test with Postman:
-
POST /v1/auth/login
to get a JWT. -
POST /v1/tasks
to create a task. -
POST /v1/categories/assign
with{"task_id": 1, "category_id": 1}
. -
GET /v1/tasks
to verify the task includes the category.
20.2 Integrating All Layers
The API’s layers are already integrated, but let’s verify and enhance the flow:
-
Data Access Layer (
TaskRepository
,UserRepository
,CategoryRepository
):- Uses PDO with prepared statements for security (Module 18).
- Supports transactions for data integrity (Module 13).
- Optimized with indexes and lazy loading (Module 17).
-
Business Logic Layer (
TaskService
,UserService
,CategoryService
,AuthService
):- Validates inputs using
TaskDTO
andValidator
(Module 11). - Sanitizes inputs to prevent XSS (Module 18).
- Caches task listings with Redis (Module 17).
- Invalidates cache on task creation/update/deletion.
- Validates inputs using
-
Presentation Layer (
TaskController
,UserController
,CategoryController
,AuthController
):- Handles routing with
Router
(Module 7, updated in Module 15 for versioning). - Applies middleware for logging, authentication, rate limiting, and secure headers (Modules 10, 18).
- Returns consistent JSON responses with error handling (Module 12).
- Handles routing with
-
Enhancement: Add Response Standardization:
- Create
src/Utils/ResponseFormatter.php
:
namespace App\Utils; class ResponseFormatter { public static function success(int $status, array $data = [], string $message = ''): array { return [ 'status' => 'success', 'code' => $status, 'message' => $message, 'data' => $data ]; } public static function error(int $status, string $message): array { return [ 'status' => 'error', 'code' => $status, 'error' => $message ]; } }
- Update
TaskController
to useResponseFormatter
:
public function index() { try { $userId = $_SERVER['USER_ID'] ?? throw new \RuntimeException('User not authenticated'); $status = $_GET['status'] ?? null; $limit = (int)($_GET['limit'] ?? 10); $page = (int)($_GET['page'] ?? 1); $dueDateStart = $_GET['due_date_start'] ?? null; $dueDateEnd = $_GET['due_date_end'] ?? null; $sort = $_GET['sort'] ?? null; $result = $this->taskService->getTasksByUser($userId, $status, $limit, $page, $dueDateStart, $dueDateEnd, $sort); $this->sendResponse(200, ResponseFormatter::success(200, [ 'tasks' => array_map(fn($task) => $task->toArray(), $result['data']), 'meta' => $result['meta'] ], 'Tasks retrieved successfully')); } catch (\Throwable $e) { ErrorHandler::handle($e); } } private function sendResponse(int $status, array $data) { http_response_code($status); header('Content-Type: application/json'); echo json_encode($data); }
- Update
ErrorHandler.php
:
use App\Utils\ResponseFormatter; class ErrorHandler { public static function handle(Throwable $exception): void { $statusCode = 500; $errorMessage = 'Internal Server Error'; switch (get_class($exception)) { case \InvalidArgumentException::class: case \App\Exceptions\ValidationException::class: $statusCode = 400; $errorMessage = $exception->getMessage(); break; case \RuntimeException::class: $statusCode = 401; $errorMessage = $exception->getMessage(); break; case \App\Exceptions\NotFoundException::class: $statusCode = 404; $errorMessage = $exception->getMessage(); break; } self::logError($exception); http_response_code($statusCode); header('Content-Type: application/json'); echo json_encode(ResponseFormatter::error($statusCode, $errorMessage)); exit; } }
- Create
20.3 Documenting the API with OpenAPI/Swagger
Clear documentation makes the API accessible to developers. We’ll use OpenAPI 3.0 and generate Swagger UI.
- Install Swagger PHP:
composer require zircote/swagger-php
-
Create
src/Docs/openapi.yaml
:
openapi: 3.0.3
info:
title: Task Management API
description: A RESTful API for managing tasks, users, and categories.
version: 1.0.0
servers:
- url: https://api.yourdomain.com/v1
description: Production server
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Task:
type: object
properties:
id: { type: integer }
user_id: { type: integer }
title: { type: string }
description: { type: string, nullable: true }
due_date: { type: string, format: date, nullable: true }
status: { type: string, enum: [pending, in_progress, completed] }
attachment: { type: string, nullable: true }
priority: { type: string, enum: [low, medium, high] }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
categories: { type: array, items: { $ref: '#/components/schemas/Category' } }
Category:
type: object
properties:
id: { type: integer }
name: { type: string }
created_at: { type: string, format: date-time }
Error:
type: object
properties:
status: { type: string, enum: [error] }
code: { type: integer }
error: { type: string }
paths:
/tasks:
get:
summary: List tasks
security: [{ bearerAuth: [] }]
parameters:
- name: status
in: query
schema: { type: string, enum: [pending, in_progress, completed] }
- name: limit
in: query
schema: { type: integer, default: 10 }
- name: page
in: query
schema: { type: integer, default: 1 }
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [success] }
code: { type: integer }
message: { type: string }
data:
type: object
properties:
tasks: { type: array, items: { $ref: '#/components/schemas/Task' } }
meta:
type: object
properties:
total: { type: integer }
page: { type: integer }
limit: { type: integer }
total_pages: { type: integer }
'401': { $ref: '#/components/schemas/Error' }
post:
summary: Create a task
security: [{ bearerAuth: [] }]
requestBody:
content:
application/json:
schema:
type: object
properties:
title: { type: string, minLength: 3, maxLength: 100 }
description: { type: string, nullable: true }
due_date: { type: string, format: date, nullable: true }
status: { type: string, enum: [pending, in_progress, completed], default: pending }
priority: { type: string, enum: [low, medium, high], default: medium }
required: [title]
responses:
'201':
description: Task created
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [success] }
code: { type: integer }
message: { type: string }
data: { $ref: '#/components/schemas/Task' }
'400': { $ref: '#/components/schemas/Error' }
/categories/assign:
post:
summary: Assign a category to a task
security: [{ bearerAuth: [] }]
requestBody:
content:
application/json:
schema:
type: object
properties:
task_id: { type: integer }
category_id: { type: integer }
required: [task_id, category_id]
responses:
'200':
description: Category assigned
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [success] }
code: { type: integer }
message: { type: string }
'400': { $ref: '#/components/schemas/Error' }
'404': { $ref: '#/components/schemas/Error' }
-
Generate Swagger UI:
- Install Swagger UI:
mkdir -p public/docs curl -L https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.17.14.tar.gz | tar -xz -C public/docs --strip-components=1 swagger-ui-5.17.14/dist
- Create
public/docs/index.php
:
<?php header('Content-Type: text/html'); readfile('index.html');
- Update
public/docs/index.html
to point toopenapi.yaml
:
<script> window.onload = function() { SwaggerUIBundle({ url: "/v1/docs/openapi.yaml", dom_id: '#swagger-ui', presets: [SwaggerUIBundle.presets.apis] }); }; </script>
- Serve
openapi.yaml
via Nginx:
location /v1/docs/openapi.yaml { alias /var/www/task-management-api/src/Docs/openapi.yaml; }
- Access Swagger UI at
https://api.yourdomain.com/docs
.
20.4 Course Recap, Best Practices, and Next Steps
-
Course Recap:
- Module 1–6: Set up the project, routing, and basic controllers.
- Module 7–10: Built the Data Access Layer (PDO, repositories), Business Logic Layer (services, authentication), and JWT middleware.
- Module 11–12: Added input validation, sanitization, and error handling.
- Module 13–14: Implemented database transactions, relationships, pagination, filtering, and file uploads.
- Module 15: Introduced API versioning and backward compatibility.
- Module 16: Added unit and integration tests with PHPUnit and Postman.
- Module 17: Optimized performance with caching, query optimization, and lazy loading.
- Module 18: Secured the API with protections against vulnerabilities, rate limiting, HTTPS, and auditing.
- Module 19: Deployed the API with Nginx, CI/CD, and monitoring.
- Module 20: Integrated all components, added a new endpoint, and documented with OpenAPI.
-
Best Practices:
- Code Organization: Use layered architecture (controllers, services, repositories) for maintainability.
- Security: Always use prepared statements, sanitize inputs, enforce HTTPS, and apply rate limiting.
- Performance: Cache frequently accessed data, optimize queries with indexes, and use lazy loading.
- Testing: Write comprehensive unit and integration tests, and automate with CI/CD.
- Documentation: Provide clear, interactive API docs with OpenAPI/Swagger.
- Versioning: Use URI versioning and maintain backward compatibility for smooth client transitions.
-
Next Steps:
- Explore Advanced Frameworks: Try Laravel or Symfony for larger projects with built-in tools.
- Add More Features: Implement role-based access control (RBAC), WebSocket for real-time updates, or GraphQL for flexible queries.
- Enhance Monitoring: Integrate Prometheus/Grafana for metrics and alerting.
- Learn Microservices: Break the API into smaller services for scalability.
- Contribute to Open Source: Apply your skills to real-world PHP projects on GitHub.
Hands-On Activity
Let’s finalize and document the API!
-
Task 1: Implement Category Assignment Endpoint
- Create
CategoryController
andCategoryService
as shown. - Test
POST /v1/categories/assign
with{"task_id": 1, "category_id": 1}
in Postman. - Verify the task includes the category in
GET /v1/tasks/1
.
- Create
-
Task 2: Standardize Responses
- Implement
ResponseFormatter.php
and updateTaskController
andErrorHandler
. - Test
GET /v1/tasks
andPOST /v1/tasks
with invalid data to verify consistent response formats.
- Implement
-
Task 3: Document with OpenAPI
- Create
openapi.yaml
and set up Swagger UI inpublic/docs
. - Access
https://api.yourdomain.com/docs
and test endpoints via Swagger UI.
- Create
-
Task 4: Run Final Tests
- Run PHPUnit tests:
vendor/bin/phpunit
. - Run Postman collection with tests for all endpoints (
/v1/auth/login
,/v1/tasks
,/v1/categories/assign
, etc.). - Verify rate limiting, secure headers, and HTTPS in production.
- Run PHPUnit tests:
-
Task 5: Update README
- Add a section to
README.md
covering: - Overview of the Task Management API and its features.
- Instructions for running the API locally and in production.
- How to access Swagger UI (
/docs
) and use the API. - Best practices learned and next steps for developers.
- Include example requests/responses and deployment instructions.
- Add a section to
Resources
- OpenAPI Specification: https://spec.openapis.org/oas/v3.0.3
- Swagger UI: https://swagger.io/tools/swagger-ui/
- Laravel API Development: https://laravel.com/docs/11.x/eloquent
- Prometheus Monitoring: https://prometheus.io/docs/introduction/overview/
- REST API Best Practices: https://restfulapi.net/
Top comments (0)