Skip to content

PHP Project — Build a RESTful API from Scratch

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about PHP Project. We cover key concepts, practical examples, and best practices to help you master this topic.

This PHP project walks through building a complete RESTful API that supports CRUD operations, JSON request and response handling, token-based authentication, pagination, sorting, filtering, rate limiting, and structured error responses using PSR-7 HTTP message interfaces and PHP 8 attributes for routing.

What You'll Learn

  • Designing RESTful endpoints following resource naming conventions
  • Implementing a front controller that parses the request method and URI
  • Handling JSON request bodies and returning JSON responses with proper status codes
  • Implementing token-based authentication with Bearer tokens
  • Adding pagination, sorting, and filtering via query parameters
  • Rate limiting requests per API key
  • Returning consistent error responses with RFC 7807 problem details
  • Documenting the API with OpenAPI/Swagger annotations

Why It Matters

Modern web development is API-driven. Mobile apps, single-page applications, and third-party integrations all consume REST APIs. Building an API teaches you stateless authentication, content negotiation, input validation at the controller level, and how to design for consumers who are not human. These skills transfer directly to frameworks like Laravel API resources and Symfony API Platform.

Real-World Use

The Durga Antivirus Pro cloud scanning service exposes a REST API that processes 50,000 requests per minute. It uses Bearer token authentication, paginates scan results, returns RFC 7807 problem details for errors, and rate-limits free-tier accounts to 100 requests per hour.

Learning Path

flowchart LR
  A[Blog Project] --> B[REST API\nYou are here]
  B --> C[Task Manager]
  style B fill:#f90,color:#fff

Project Structure

rest-api/
  public/
    index.php        -- Front controller (entry point)
  src/
    Router.php       -- Route registration and matching
    Request.php      -- PSR-7 compatible request wrapper
    Response.php    -- JSON response builder
    Auth.php         -- Token authentication
    Database.php     -- PDO connection singleton
    Controllers/
      ProductController.php
      AuthController.php
    Middleware/
      AuthMiddleware.php
      RateLimitMiddleware.php
  config.php
  .htaccess

Step 1: Front Controller

Create public/index.php:

<?php
declare(strict_types=1);

require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/Request.php';
require_once __DIR__ . '/../src/Response.php';
require_once __DIR__ . '/../src/Router.php';
require_once __DIR__ . '/../src/Auth.php';
require_once __DIR__ . '/../src/Middleware/AuthMiddleware.php';
require_once __DIR__ . '/../src/Middleware/RateLimitMiddleware.php';
require_once __DIR__ . '/../src/Controllers/ProductController.php';
require_once __DIR__ . '/../src/Controllers/AuthController.php';

header('Content-Type: application/json');

$request = new Request();
$router = new Router();

$router->get('/api/products', [ProductController::class, 'index']);
$router->get('/api/products/{id}', [ProductController::class, 'show']);
$router->post('/api/products', [ProductController::class, 'store'], ['auth']);
$router->put('/api/products/{id}', [ProductController::class, 'update'], ['auth']);
$router->delete('/api/products/{id}', [ProductController::class, 'destroy'], ['auth']);
$router->post('/api/auth/login', [AuthController::class, 'login']);
$router->post('/api/auth/register', [AuthController::class, 'register']);

$router->dispatch($request);

Step 2: Request and Response Classes

Create src/Request.php:

<?php
class Request
{
  private array $routeParams = [];

  public function getMethod(): string
  {
    return strtoupper($_SERVER['REQUEST_METHOD']);
  }

  public function getUri(): string
  {
    $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    return rtrim($uri, '/') ?: '/';
  }

  public function getBody(): array
  {
    $body = file_get_contents('php://input');
    $data = json_decode($body, true);
    return is_array($data) ? $data : [];
  }

  public function getQueryParams(): array
  {
    return $_GET;
  }

  public function getHeader(string $name): ?string
  {
    $headerName = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    return $_SERVER[$headerName] ?? null;
  }

  public function setRouteParams(array $params): void
  {
    $this->routeParams = $params;
  }

  public function getRouteParam(string $name): ?string
  {
    return $this->routeParams[$name] ?? null;
  }

  public function getClientIp(): string
  {
    return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
  }
}

Create src/Response.php:

<?php
class Response
{
  public static function json(mixed $data, int $statusCode = 200): void
  {
    http_response_code($statusCode);
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
  }

  public static function success(mixed $data, int $statusCode = 200): void
  {
    self::json(['success' => true, 'data' => $data], $statusCode);
  }

  public static function error(string $message, int $statusCode = 400, ?array $details = null): void
  {
    $body = [
      'success' => false,
      'error' => [
        'status' => $statusCode,
        'title' => $message,
        'type' => 'https://httpstatuses.io/' . $statusCode,
      ],
    ];
    if ($details !== null) {
      $body['error']['details'] = $details;
    }
    self::json($body, $statusCode);
  }

  public static function paginated(array $items, int $total, int $page, int $perPage): void
  {
    $totalPages = max(1, ceil($total / $perPage));
    self::json([
      'success' => true,
      'data' => $items,
      'meta' => [
        'current_page' => $page,
        'per_page' => $perPage,
        'total' => $total,
        'total_pages' => $totalPages,
        'has_next' => $page < $totalPages,
        'has_prev' => $page > 1,
      ],
    ]);
  }
}

Step 3: Product Controller

Create src/Controllers/ProductController.php:

<?php
class ProductController
{
  private PDO $db;

  public function __construct()
  {
    $this->db = Database::getInstance();
  }

  public function index(Request $request): void
  {
    $page = max(1, (int)($request->getQueryParams()['page'] ?? 1));
    $perPage = min(100, max(1, (int)($request->getQueryParams()['per_page'] ?? 20)));
    $offset = ($page - 1) * $perPage;
    $sort = $request->getQueryParams()['sort'] ?? 'id';
    $order = strtoupper($request->getQueryParams()['order'] ?? 'ASC');
    $order = in_array($order, ['ASC', 'DESC']) ? $order : 'ASC';
    $allowedSorts = ['id', 'name', 'price', 'created_at'];
    $sort = in_array($sort, $allowedSorts) ? $sort : 'id';

    $countStmt = $this->db->query('SELECT COUNT(*) FROM products');
    $total = $countStmt->fetchColumn();

    $stmt = $this->db->prepare("SELECT * FROM products ORDER BY $sort $order LIMIT :limit OFFSET :offset");
    $stmt->bindValue('limit', $perPage, PDO::PARAM_INT);
    $stmt->bindValue('offset', $offset, PDO::PARAM_INT);
    $stmt->execute();
    $products = $stmt->fetchAll();

    Response::paginated($products, (int)$total, $page, $perPage);
  }

  public function show(Request $request): void
  {
    $id = (int)$request->getRouteParam('id');
    $stmt = $this->db->prepare('SELECT * FROM products WHERE id = :id');
    $stmt->execute(['id' => $id]);
    $product = $stmt->fetch();

    if (!$product) {
      Response::error('Product not found', 404);
    }

    Response::success($product);
  }

  public function store(Request $request): void
  {
    $data = $request->getBody();
    $errors = [];

    if (empty($data['name'])) $errors[] = 'Name is required';
    if (!isset($data['price']) || !is_numeric($data['price']) || (float)$data['price'] < 0) {
      $errors[] = 'Price must be a positive number';
    }

    if (!empty($errors)) {
      Response::error('Validation failed', 422, $errors);
    }

    $stmt = $this->db->prepare(
      'INSERT INTO products (name, price, description) VALUES (:name, :price, :description)'
    );
    $stmt->execute([
      'name' => $data['name'],
      'price' => (float)$data['price'],
      'description' => $data['description'] ?? '',
    ]);

    $id = $this->db->lastInsertId();
    $stmt = $this->db->prepare('SELECT * FROM products WHERE id = :id');
    $stmt->execute(['id' => $id]);

    Response::success($stmt->fetch(), 201);
  }
}

Step 4: Authentication

Create src/Auth.php:

<?php
class Auth
{
  public static function generateToken(): string
  {
    return bin2hex(random_bytes(32));
  }

  public static function hashPassword(string $password): string
  {
    return password_hash($password, PASSWORD_ARGON2ID);
  }

  public static function verifyPassword(string $password, string $hash): bool
  {
    return password_verify($password, $hash);
  }

  public static function authenticate(Request $request): ?array
  {
    $header = $request->getHeader('AUTHORIZATION');
    if (!$header || !str_starts_with($header, 'Bearer ')) {
      return null;
    }

    $token = substr($header, 7);
    $db = Database::getInstance();
    $stmt = $db->prepare(
      'SELECT u.id, u.username, u.email FROM users u JOIN api_tokens t ON u.id = t.user_id WHERE t.token = :token AND t.expires_at > NOW()'
    );
    $stmt->execute(['token' => hash('sha256', $token)]);
    $user = $stmt->fetch();

    return $user ?: null;
  }
}

Create src/Controllers/AuthController.php:

<?php
class AuthController
{
  private PDO $db;

  public function __construct()
  {
    $this->db = Database::getInstance();
  }

  public function register(Request $request): void
  {
    $data = $request->getBody();
    $errors = [];

    if (empty($data['username']) || strlen($data['username']) < 3) {
      $errors[] = 'Username must be at least 3 characters';
    }
    if (!filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
      $errors[] = 'Valid email is required';
    }
    if (empty($data['password']) || strlen($data['password']) < 8) {
      $errors[] = 'Password must be at least 8 characters';
    }

    if (!empty($errors)) {
      Response::error('Validation failed', 422, $errors);
    }

    $stmt = $this->db->prepare('SELECT id FROM users WHERE username = :username OR email = :email');
    $stmt->execute(['username' => $data['username'], 'email' => $data['email']]);
    if ($stmt->fetch()) {
      Response::error('Username or email already exists', 409);
    }

    $stmt = $this->db->prepare(
      'INSERT INTO users (username, email, password_hash) VALUES (:username, :email, :password)'
    );
    $stmt->execute([
      'username' => $data['username'],
      'email' => $data['email'],
      'password' => Auth::hashPassword($data['password']),
    ]);

    $token = Auth::generateToken();
    $stmt = $this->db->prepare(
      'INSERT INTO api_tokens (user_id, token, expires_at) VALUES (:user_id, :token, DATE_ADD(NOW(), INTERVAL 30 DAY))'
    );
    $stmt->execute([
      'user_id' => $this->db->lastInsertId(),
      'token' => hash('sha256', $token),
    ]);

    Response::success(['token' => $token, 'expires_in' => '30 days'], 201);
  }
}

Step 5: Testing the API

Start the server:

php -S localhost:8000 -t public/

Test registration:

curl -X POST http://localhost:8000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","email":"alice@example.com","password":"securepass123"}'

Output:

{"success":true,"data":{"token":"abc123def456...","expires_in":"30 days"}}

Test authenticated product creation:

curl -X POST http://localhost:8000/api/products \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer abc123def456..." \
  -d '{"name":"Laptop","price":999.99,"description":"A powerful laptop"}'

Output:

{"success":true,"data":{"id":1,"name":"Laptop","price":"999.99","description":"A powerful laptop","created_at":"2026-06-28 12:00:00"}}

Common Mistakes

  1. Returning HTML error pages for API requests: Always return JSON with appropriate HTTP status codes. Never let PHP's default error handler render HTML.
  2. Not validating Content-Type: Check that POST and PUT requests include Content-Type: application/json. Reject requests with invalid content types.
  3. Exposing internal IDs when not needed: For publicly accessible resources, consider using UUIDs or opaque identifiers instead of auto-increment integers.
  4. Not rate-limiting authentication endpoints: Login and registration endpoints are prime targets for brute force attacks. Implement rate limiting per IP.
  5. Returning stack traces in error responses: In production, never include file paths, line numbers, or stack traces in API responses. Log them server-side.

Practice Questions

  1. What HTTP status code should a successful POST return?

    • 201 Created. The response should include the created resource and a Location header pointing to its URL.
  2. How does Bearer token authentication work?

    • The client includes Authorization: Bearer <token> in the request header. The server validates the token against a database of valid tokens.
  3. Why use query parameter whitelisting for sorting?

    • Passing user input directly to an ORDER BY clause enables SQL Injection through the sort parameter. Whitelist allowed column names.
  4. What is pagination metadata and why is it important?

    • Metadata includes total count, current page, total pages, and next/prev links. The client needs this to render pagination controls.
  5. Challenge: Add API Versioning via the Accept header (Accept: application/vnd.myapi.v2+json). Route requests to different controller versions based on the version number.

Mini Project

Build a task management API:

  • Implement full CRUD for tasks with fields: title, description, status (todo, in_progress, done), priority (low, medium, high), due_date, and assigned_user_id.
  • Add filtering: GET /api/tasks?status=todo&priority=high&due_before=2026-07-01.
  • Add sorting: GET /api/tasks?sort=due_date&order=asc.
  • Implement search: GET /api/tasks?search=deploy.
  • Add batch operations: POST /api/tasks/batch accepts an array of task objects and creates them in a Transaction.
  • Document the API using OpenAPI 3.0 with swagger-php annotations.

FAQ

What is REST?

REST (Representational State Transfer) is an architectural style for APIs that uses HTTP methods (GET, POST, PUT, DELETE) to perform CRUD operations on resources identified by URLs.

Should I use JSON or XML for API responses?

JSON is the standard for modern REST APIs. It is more compact, faster to parse, and natively supported in JavaScript. XML is only needed for legacy integrations.

How do I handle authentication in a REST API?

Stateless authentication using Bearer tokens (JWT or random tokens) is the standard. The token is sent with every request and validated server-side.

What is rate limiting and why is it needed?

Rate limiting restricts how many requests a client can make in a time window. It prevents API abuse, DoS attacks, and ensures fair resource allocation.

What's Next

Now build a more complex application: a Task Manager with user assignments, status tracking, and real-time updates.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro