DEV Community

Cover image for Build a Robust RESTful API with PHP 8, from Scratch Course!
CodeWithDhanian
CodeWithDhanian

Posted on

Build a Robust RESTful API with PHP 8, from Scratch Course!

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

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:
    • Windows: Download the PHP 8 binary from php.net and add it to your system’s PATH.
    • macOS: Use Homebrew (brew install php@8.0) or download from php.net.
    • Linux: Install via package manager (e.g., sudo apt-get install php8.0 on Ubuntu) or compile from source.
  • Verifying Installation: Run php -v in your terminal to confirm PHP 8 is installed.
  • Required Extensions:
    • pdo and pdo_mysql (or pdo_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.
  • 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 via http://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 a composer.json file.
    • Example composer.json:
    {
        "name": "yourname/task-management-api",
        "description": "A RESTful API built with PHP 8",
        "require": {
            "php": "^8.0",
            "ext-pdo": "*"
        }
    }
    
  • Adding Dependencies:

    • Install common libraries like vlucas/phpdotenv for environment variables:
    composer require vlucas/phpdotenv
    
    • Autoload classes using PSR-4 standards.
  • Autoloading Setup: Configure composer.json to autoload your project’s classes:

  "autoload": {
      "psr-4": {
          "App\\": "src/"
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 your phpinfo() 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, and json extensions are enabled.
    • Create a simple test.php file to output phpinfo() and view it in a browser.
  • 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 basic index.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.
  • Task 4: Explore Postman
    • Install Postman and create a test request to http://localhost.
    • Save the request in a collection named “Task Management API.”

Resources

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"}.
  • 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 like INSERT INTO tasks or SELECT * FROM tasks WHERE user_id = 1.
    • Example: When TaskService needs to save a task, it calls TaskRepository, which executes the database query and returns the result.
  • Optional Layers:
    • Domain Layer: Defines core entities (e.g., a Task class with properties like title and due_date).
    • Infrastructure Layer: Manages external services like logging, email sending, or third-party API calls.
  • 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 and PUT /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 the tasks 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:

    1. Client sends: POST /tasks with {"title": "Buy groceries", "due_date": "2025-10-15"}.
    2. TaskController receives the JSON, extracts data, and calls TaskService::createTask().
    3. TaskService validates the title and due date, then calls TaskRepository::create().
    4. TaskRepository executes an INSERT query and returns the new task ID.
    5. TaskController returns: {"status": "success", "id": 1}.

Another Example: For GET /tasks/1, the flow is similar:

  • TaskController calls TaskService::getTaskById(1).
  • TaskService calls TaskRepository::findById(1).
  • TaskRepository runs SELECT * 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
Enter fullscreen mode Exit fullscreen mode
  • 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;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Example .env File:
  DB_HOST=localhost
  DB_NAME=task_management
  DB_USER=root
  DB_PASS=
  API_ENV=development
Enter fullscreen mode Exit fullscreen mode
  • 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, and Task.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 simple Task class and loading it in a test script.
  • Task 3: Map an Endpoint’s Flow

    • Choose an endpoint for our API (e.g., POST /tasks or GET /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.
  • 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.
  • 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

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 the vendor/ folder and autoloader.
  • Why Composer?

    • Manages dependencies like vlucas/phpdotenv for environment variables.
    • Autoloads classes following PSR-4 standards, saving us from manual require statements.
  • 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!”.

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;
        }
    }
    
  • 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.

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']);
        }
    }
    
  • 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();
    
  • 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 to http://localhost:8000/api/test.
    • Expect: {"message": "Welcome to the Task Management API!"}.

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']);
        }
    }
    
  • 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();
    
  • 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)
    

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 configure composer.json as shown above.
    • Install vlucas/phpdotenv and verify autoloading with the HelloWorld test.
  • 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).
  • Task 3: Test the Routing System
    • Implement the Router.php class and update index.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.
  • 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”).

Resources

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 and tasks tables.
    • Insert a test user:
    INSERT INTO users (username, email, password) VALUES ('testuser', 'test@example.com', 'hashed_password');
    

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’s docker-compose.yml.
  • Creating the Database:

    • Connect to MySQL: mysql -u root -p.
    • Create the database:
    CREATE DATABASE task_management;
    
    • Verify with SHOW DATABASES;.
  • 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 with SERIAL).

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;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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.

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]);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
          ];
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Explanation:
    • TaskRepository extends BaseRepository to inherit generic methods (findById, findAll, delete).
    • create and update handle task-specific fields with prepared statements for security.
    • findByUserId retrieves tasks for a specific user, useful for GET /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 and tasks 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');
    
  • Task 2: Test the Database Connection

    • Implement Database.php and run the test_db_connection.php script.
    • Verify the connection works by checking for “Database connection successful!”.
  • Task 3: Implement and Test BaseRepository

    • Create BaseRepository.php and test its findAll 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.
  • Task 4: Test TaskRepository

    • Implement TaskRepository.php and Task.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.
  • 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.

Resources

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 and UNIQUE constraints on username and email for data integrity.
    • Tasks Table: Adds in_progress to the status enum for more flexibility and uses ON DELETE CASCADE to remove tasks if a user is deleted.
    • Indexes: Foreign key on user_id and index on status for faster queries (e.g., CREATE INDEX idx_status ON tasks(status);).
  • 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');
    

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
          ];
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
          ];
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • How It Works:
    • TaskRepository and UserRepository return Task and User objects, respectively, for type-safe data handling.
    • password_hash in UserRepository 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";
Enter fullscreen mode Exit fullscreen mode
  • Expected Output:
  Created User: jane_doe
  Found User: jane@example.com
  Created Task ID: 2
  Updated Task Status: completed
  User Tasks: 1
Enter fullscreen mode Exit fullscreen mode

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.
  • Task 2: Implement Entity Classes
    • Create Task.php and User.php as shown.
    • Test the Task class by creating a new instance and calling toArray() in a script.
  • Task 3: Update and Test Repositories
    • Update TaskRepository.php and create UserRepository.php.
    • Run the test_crud.php script to verify CRUD operations.
    • Debug any errors (e.g., check .env credentials or table structure).
  • Task 4: Add a Custom Entity Method

    • Add a method to Task.php (e.g., isOverdue() to check if due_date is past today).
    • Test it in a script:
    $task = new Task(['due_date' => '2025-10-01']);
    echo $task->isOverdue() ? "Overdue" : "Not overdue";
    
  • 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.

Resources

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) in src/Services/.
    • Each service handles one domain (tasks, users) and depends on repositories for data access.
  • Example Workflow:
    • A POST /tasks request hits the controller, which calls TaskService::createTask().
    • TaskService validates the input and calls TaskRepository::create().
    • The result flows back to the controller for a JSON response.

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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 and updateTask.
    • Services can be called by multiple controllers or even future CLI scripts.
  • Example:
    • TaskService::createTask can be reused for POST /tasks and a future POST /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.
  • Error Handling:
    • Throw specific exceptions (InvalidArgumentException for input errors, RuntimeException for logic errors).
    • Return meaningful error messages for client consumption.
  • 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());
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
    }
    
  • Expected Output:

  Created User: alice
  Created Task: Test Task
  Error: Task title must be at least 3 characters long
Enter fullscreen mode Exit fullscreen mode

Hands-On Activity

Let’s put the Business Logic Layer to work with practical tasks!

  • Task 1: Implement Service Classes
    • Create TaskService.php and UserService.php as shown.
    • Run the test_services.php script to verify user and task creation.
  • 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.
  • Task 3: Test Authorization
    • Try updating a task with a different userId in TaskService::updateTask.
    • Verify that it throws an “unauthorized” exception.
  • 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.
  • 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

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]);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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]);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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"}.
  • 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() and sendError() 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"}
  • 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).

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();
Enter fullscreen mode Exit fullscreen mode
  • 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 and UserController.php as shown.
    • Update index.php with the new routes.
  • 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 to TaskController to filter tasks by status.
    • Update TaskService and TaskRepository to support this (e.g., add findByStatus method).
    • Test it with GET /api/tasks/status/pending in Postman.
  • 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.
  • Task 5: Update README
    • Update README.md with the new endpoints and example requests/responses.
    • Include instructions for testing with Postman.

Resources

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']);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • 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 passes 1 as $id to the controller.
    • Handled by the regex in Router::dispatch().
  • 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());
        }
    }
    
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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']);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • 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 [];
  }
Enter fullscreen mode Exit fullscreen mode
  • Update UserController::getRequestData:
    • Copy the same method from TaskController.
  • 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).

Hands-On Activity

Let’s test and enhance our routing system!

  • Task 1: Implement the Enhanced Router
    • Update Router.php and index.php with the new code.
    • Test all endpoints (GET /api/tasks, POST /api/tasks, etc.) in Postman with the X-API-KEY header set to your-secret-key.
  • 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.
  • Task 3: Create a New Middleware
    • Create a RateLimitMiddleware in src/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.
  • 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.
  • 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.

Resources

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
Enter fullscreen mode Exit fullscreen mode
  • Update .env with a Secret Key:
  JWT_SECRET=your-secure-jwt-secret
  JWT_EXPIRY=3600 # Token expires in 1 hour
Enter fullscreen mode Exit fullscreen mode
  • 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');
          }
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 in UserRepository).

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;
          }
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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]);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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]);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • Key Features:
    • AuthMiddleware checks for a valid Authorization: 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');
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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());
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Update index.php:
  $router->post('/auth/refresh', fn() => $authController->refresh(), [RequestLogger::class]);
Enter fullscreen mode Exit fullscreen mode
  • 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.

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 with JWT_SECRET.
    • Implement AuthService.php, AuthController.php, and AuthMiddleware.php.
  • 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 for GET /api/tasks.
    • Verify access and try with an invalid token to see a 401 error.
  • Task 3: Test Token Refresh
    • Send POST /api/auth/refresh with a valid token in the Authorization header.
    • Verify a new token is returned with an updated exp claim.
  • Task 4: Add Role-Based Authorization
    • Modify User.php to add a role field (e.g., admin or user).
    • Update UserRepository and UserService to handle roles.
    • Add a check in AuthMiddleware to allow only admin users to access GET /api/users/{id}.
    • Test with a non-admin user to verify a 403 error.
  • 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 and POST /api/auth/refresh.

Resources

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).
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
      ) {}
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
          ];
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • Security Practices:
    • Use filter_var with FILTER_SANITIZE_STRING and FILTER_SANITIZE_EMAIL to strip harmful characters.
    • Rely on PDO’s prepared statements (already in TaskRepository and UserRepository) to prevent SQL injection.
    • Avoid XSS by sanitizing outputs in controllers if rendering HTML (not applicable here, but good practice for future).

Hands-On Activity

Let’s strengthen our API with validation and sanitization!

  • Task 1: Implement Validation with Attributes
    • Create Validate.php, Validator.php, and TaskDTO.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.
  • Task 2: Add Custom Validation Rule
    • Implement DueDateNotPast.php and update Validator.php and TaskDTO.php.
    • Test POST /api/tasks with a past due date (e.g., 2025-10-10) and verify the error.
  • Task 3: Sanitize Inputs
    • Update TaskService and UserService to include sanitization.
    • Test by sending malicious input (e.g., {"title": "<script>alert('hack')</script>"}) and verify it’s sanitized.
  • Task 4: Create a UserDTO
    • Create src/DTO/UserDTO.php with validation attributes for username, email, and password.
    • Update UserService::createUser to use it.
    • Test POST /api/users with invalid data (e.g., short username, invalid email).
  • Task 5: Update README
    • Add a section to README.md describing the validation and sanitization process.
    • Include example error responses for invalid inputs.

Resources

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).
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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.

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 {}
Enter fullscreen mode Exit fullscreen mode
  • Create src/Exceptions/ValidationException.php:
  namespace App\Exceptions;

  class ValidationException extends \InvalidArgumentException {}
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
  }
Enter fullscreen mode Exit fullscreen mode
  • Why Custom Exceptions?
    • Specific exceptions (NotFoundException, ValidationException) clarify error types.
    • Easier to map to HTTP status codes in ErrorHandler.
    • Improves code readability and maintainability.

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();
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Create a Log Directory:
    • Create a logs/ folder in the project root with write permissions (e.g., chmod 775 logs).
  • Update ErrorHandler Logging (already included in logError 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: ...
Enter fullscreen mode Exit fullscreen mode
  • Example Log Output (logs/requests.log):
  [2025-10-12 17:03:00] POST /api/tasks from 127.0.0.1
Enter fullscreen mode Exit fullscreen mode
  • 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"
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode

Hands-On Activity

Let’s implement and test our error-handling and logging system!

  • Task 1: Implement ErrorHandler
    • Create ErrorHandler.php, NotFoundException.php, and ValidationException.php.
    • Update TaskController.php and AuthController.php to use ErrorHandler.
  • 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.
  • Task 4: Add a Custom Exception
    • Create a ForbiddenException for unauthorized actions (e.g., updating another user’s task).
    • Update TaskService to throw it and ErrorHandler to map it to HTTP 403.
    • Test by attempting to update a task with a different user ID.
  • Task 5: Update README
    • Add a section to README.md describing the error-handling system, including example error responses and log file locations.

Resources

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(), and rollBack().
    • Wrap related database operations in a transaction block.
  • 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());
          }
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 in tasks table).
    • Example: TaskRepository::findByUserId retrieves all tasks for a user.
  • Many-to-Many: Tasks and Categories

    • Add a categories table and a task_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);
    
  • 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
          ];
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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.

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;
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 ?? [])
      ];
  }
Enter fullscreen mode Exit fullscreen mode
  • 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; }
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode
  • Why Indexing?
    • Speeds up queries like SELECT * FROM tasks WHERE user_id = ?.
    • Reduces load on the database for large datasets.
    • idx_user_id optimizes findByUserId, idx_status helps with filtering.
  • 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 and task_category tables.
    • Insert sample categories and task-category relationships.
  • Task 2: Implement Transactions
    • Update TaskRepository.php to use transactions for create and update.
    • Test by creating a task and intentionally causing an error (e.g., invalid user_id) to verify rollback.
  • Task 3: Test Relational Queries

    • Implement CategoryRepository.php and update TaskRepository::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());
    
  • Task 4: Optimize with Indexes

    • Add indexes to tasks and task_category tables.
    • Run EXPLAIN SELECT * FROM tasks WHERE user_id = 1; to verify index usage.
  • 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.

Resources

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
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Why This Approach?
    • Returns metadata (total, page, total_pages) for client navigation.
    • Supports flexible limit and page query parameters.

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 in TaskService::getTasksByUser.
    • Add support for filtering by due_date range.
  • Sorting:
    • Support sorting by title, due_date, or status (e.g., sort=title:asc).
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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)
          ]
      ];
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
Enter fullscreen mode Exit fullscreen mode
  • 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 ?? [])
      ];
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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());
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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

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);
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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)
          ]
      ];
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Update index.php:
  $router->get('/tasks/search', fn() => $taskController->search(), [RequestLogger::class, AuthMiddleware::class]);
Enter fullscreen mode Exit fullscreen mode

Hands-On Activity

Let’s implement and test these advanced features!

  • Task 1: Implement Pagination
    • Update TaskService, TaskController, and TaskRepository for pagination.
    • Test GET /api/tasks?page=2&limit=5 in Postman and verify metadata.
  • 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.
  • Task 3: Implement File Uploads
    • Add the attachment column to the tasks table.
    • Create FileUploader.php and update TaskService and TaskRepository.
    • 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.
  • 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.

Resources

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).
  • 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.
  • 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.
  • 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.
  • 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/
    
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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/"
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Run composer dump-autoload.
    • Update Router.php to Support Versioned Routes:
  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']);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • 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.

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']);
    }
    
  • Deprecation Header:

    • Adds X-Deprecated header to warn clients about old endpoints.
    • Redirects to the new /v1 endpoint for seamless transition.
  • 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 and update:
    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());
        }
    }
    
  • 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 for v1 controllers.
    • Modify Router.php and index.php to support /v1 routes.
    • Test GET /v1/tasks and POST /v1/tasks in Postman to verify functionality.
  • 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.
  • Task 3: Add Backward-Compatible Field
    • Update the database schema to add priority to the tasks table.
    • Update Task.php, TaskDTO.php, and TaskRepository.php to handle priority.
    • Test POST /v1/tasks with and without priority to confirm compatibility.
  • 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., renames due_date to deadline).
    • Update Router.php to support /v2 routes and test both /v1 and /v2 endpoints.
  • 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.

Resources

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
Enter fullscreen mode Exit fullscreen mode
  • 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 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 Test Directory Structure:

  tests/
  ├── Unit/
  │   ├── TaskServiceTest.php
  │   ├── UserServiceTest.php
  │   └── TaskRepositoryTest.php
  ├── Integration/
  │   └── TaskControllerTest.php
  └── bootstrap.php
Enter fullscreen mode Exit fullscreen mode
  • 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');
Enter fullscreen mode Exit fullscreen mode
  • Run Tests:
  vendor/bin/phpunit
Enter fullscreen mode Exit fullscreen mode

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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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());
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 and tearDown.

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']);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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 the Authorization: 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.
  • 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).

Hands-On Activity

Let’s implement and run our test suite!

  • Task 1: Set Up PHPUnit
    • Install PHPUnit and Mockery, create phpunit.xml and tests/bootstrap.php.
    • Create the test database and apply the schema.
  • Task 2: Write Unit Tests
    • Implement TaskServiceTest.php, UserServiceTest.php, and TaskRepositoryTest.php.
    • Run vendor/bin/phpunit tests/Unit and verify all tests pass.
  • Task 3: Write Integration Tests
    • Implement TaskControllerTest.php.
    • Run vendor/bin/phpunit tests/Integration and verify controller behavior.
  • Task 4: Test with Postman
    • Create a Postman collection with requests for all endpoints.
    • Add test scripts for POST /v1/tasks and GET /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.

Resources

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
    
  • Configure Redis in .env:

  REDIS_HOST=127.0.0.1
  REDIS_PORT=6379
  CACHE_TTL=3600 # Cache time-to-live in seconds (1 hour)
Enter fullscreen mode Exit fullscreen mode
  • 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]);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • 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.
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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.

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
  }
Enter fullscreen mode Exit fullscreen mode
  • 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;
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
Enter fullscreen mode Exit fullscreen mode
  • 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.

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 in php.ini:
    [xdebug]
    xdebug.mode=profile
    xdebug.output_dir=/tmp
    
  • Profile with Xdebug:

    • Enable profiling for a request (e.g., GET /v1/tasks with XDEBUG_PROFILE=1 in the query string).
    • Analyze the cachegrind file using a tool like Webgrind or KCachegrind.
  • 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).
  • 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";
Enter fullscreen mode Exit fullscreen mode
  • 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 and index.php to use caching.
    • Test GET /v1/tasks with Postman and verify cache hits in Redis (redis-cli MONITOR).
  • 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.
  • Task 3: Implement Lazy Loading
    • Update Task.php and TaskRepository for lazy loading categories.
    • Test GET /v1/tasks and confirm categories are only loaded when accessed.
  • Task 4: Profile and Benchmark
    • Install Xdebug and profile GET /v1/tasks with and without caching.
    • Run ab to benchmark GET /v1/tasks before and after optimizations.
    • Compare results and document improvements.
  • 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.

Resources

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 and UserRepository (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.
  • 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.

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();
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • Enhancements:
    • Uses USER_ID for authenticated users, falling back to REMOTE_ADDR for unauthenticated requests.
    • Adds Retry-After header to inform clients when to retry.
    • Limits to 100 requests per minute, adjustable via configuration.

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 in public/):
    <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]);
    
  • 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 to self, 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();
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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);
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • 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();
  }
Enter fullscreen mode Exit fullscreen mode
  • Monitoring Setup:

    • Create a log directory (logs/) with write permissions (chmod 775 logs).
    • Review logs/security.log and logs/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.

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.
  • 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.
  • 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 in security.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.
  • 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.

Resources

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.
  • Secure Database Credentials:

    • Store DB_USER and DB_PASS in a secure vault (e.g., AWS Secrets Manager) or restricted file.
    • Restrict database access to the production server’s IP.
  • 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.
  • Secure File Uploads:

    • Restrict upload directory permissions (chmod 750 uploads).
    • Validate file types and sizes in FileUploader.php (Module 14).
  • 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
    
```logrotate
/path/to/task-management-api/logs/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    create 0640 www-data www-data
}
```
Enter fullscreen mode Exit fullscreen mode

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
    
  • 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
Enter fullscreen mode Exit fullscreen mode
  • Configure GitHub Secrets:
    • Add SSH_PRIVATE_KEY, SERVER_HOST, and SERVER_USER in your GitHub repository’s Settings > Secrets and variables > Actions.
  • 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.

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 and logs/errors.log.
  • 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]);
    

Hands-On Activity

Let’s deploy and monitor our API!

  • Task 1: Prepare for Production
    • Create .env.production and update php.ini for production settings.
    • Set up log rotation and verify logs/ is writable.
    • Test locally with APP_ENV=production to ensure no errors.
  • 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 with curl 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.
  • 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.

Resources

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, and CategoryRepository for database operations, transactions, and relationships.
    • Business Logic Layer (Modules 9–11, 14): Implements TaskService, UserService, and AuthService 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.
  • 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 and Validator (Module 11).
    • Sanitizes inputs to prevent XSS (Module 18).
    • Caches task listings with Redis (Module 17).
    • Invalidates cache on task creation/update/deletion.
  • 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).
  • 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 use ResponseFormatter:
    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;
        }
    }
    

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
Enter fullscreen mode Exit fullscreen mode
  • 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' }
Enter fullscreen mode Exit fullscreen mode
  • 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 to openapi.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 and CategoryService 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.
  • Task 2: Standardize Responses
    • Implement ResponseFormatter.php and update TaskController and ErrorHandler.
    • Test GET /v1/tasks and POST /v1/tasks with invalid data to verify consistent response formats.
  • Task 3: Document with OpenAPI
    • Create openapi.yaml and set up Swagger UI in public/docs.
    • Access https://api.yourdomain.com/docs and test endpoints via Swagger UI.
  • 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.
  • 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.

Resources

Top comments (0)