PHP Project — Build a RESTful API from Scratch
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
- Returning HTML error pages for API requests: Always return JSON with appropriate HTTP status codes. Never let PHP's default error handler render HTML.
- Not validating Content-Type: Check that POST and PUT requests include
Content-Type: application/json. Reject requests with invalid content types. - Exposing internal IDs when not needed: For publicly accessible resources, consider using UUIDs or opaque identifiers instead of auto-increment integers.
- Not rate-limiting authentication endpoints: Login and registration endpoints are prime targets for brute force attacks. Implement rate limiting per IP.
- 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
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.
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.
- The client includes
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.
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.
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/batchaccepts an array of task objects and creates them in a Transaction. - Document the API using OpenAPI 3.0 with swagger-php annotations.
FAQ
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