What is a REST API and Why Build One in PHP?
You need your web application to share data with other systems. Perhaps a mobile app must display the same content as your website. Maybe a third-party service needs to push updates into your platform, or your frontend JavaScript needs to communicate with a backend without reloading pages. A REST API solves this by providing a structured way for different software systems to exchange data over HTTP.
PHP has built-in capabilities that make it well-suited for API development. The language handles HTTP requests, processes JSON data, and manages server-side logic without requiring additional frameworks. Building your first REST API in PHP from scratch helps you understand how requests move through a system, which makes debugging easier and gives you a solid foundation for working with API frameworks later.
This guide walks through building a functional REST API using plain PHP. You will set up routing, handle JSON input and output, manage database operations, implement basic authentication, and test your endpoints. The approach here prioritises clarity and maintainability over brevity.
REST API Conventions Worth Understanding First
REST is not a formal specification with strict rules. It is a set of conventions that make APIs predictable for developers who consume them. Understanding these conventions before writing code prevents common design mistakes that are difficult to fix later.
HTTP Methods and Their Meaning
Each HTTP method carries a specific intention in a REST API context:
- GET: Retrieves data without modifying anything on the server. GET requests should not change resource state and are safe to cache and repeat.
- POST: Creates a new resource. Each POST request typically generates a new record with a unique identifier.
- PUT: Replaces a resource entirely. Sending incomplete data clears the missing fields, which is why PUT requires all fields.
- PATCH: Updates specific fields on a resource. Only the fields you send get changed.
- DELETE: Removes a resource permanently. Deleted resources are typically not recoverable.
URL Structure for REST APIs
REST URLs represent resources using nouns, not verbs. The HTTP method determines the action rather than embedding it into the URL path. This separation keeps your API surface clean and predictable.
# Well-structured REST API endpoints
GET /articles # Retrieve all articles
GET /articles/42 # Retrieve article with ID 42
POST /articles # Create a new article
PUT /articles/42 # Replace article 42 entirely
PATCH /articles/42 # Update article 42 partially
DELETE /articles/42 # Remove article 42
# Poor REST API design that many beginners use
POST /getArticle.php
POST /deleteArticle.php
POST /createNewArticle.php
The first approach scales cleanly as you add more resources. The second approach requires a new endpoint for every operation on every resource, which becomes unmanageable quickly. Modern PHP APIs return data in JSON format by default. XML remains supported in some legacy systems but JSON has become the standard for new development across the UK and globally.
Setting Up a Clean Project Structure
A organised project structure makes maintenance easier as your API grows. For a PHP REST API, a simple directory layout keeps concerns separated and makes it straightforward to find and modify specific parts of your codebase.
project/
├── public/
│ └── index.php # Front controller handling all requests
├── src/
│ ├── Controllers/
│ │ └── ArticleController.php
│ ├── Models/
│ │ └── Article.php
│ └── Database.php
├── .htaccess # URL rewriting rules
├── composer.json
└── .env # Environment variables for sensitive data
The public/index.php file acts as a single entry point for all API requests. This front controller pattern centralises routing logic and makes it simpler to apply security measures uniformly across your entire API. Any request that does not match an existing file gets routed through this single entry point.
Building the Front Controller and Router
The front controller receives every request, parses the URL, and determines which controller and action should handle it. This separation of request handling from business logic keeps your code organised and easier to test.
<?php
// public/index.php
declare(strict_types=1);
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// Handle preflight requests for CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
require_once __DIR__ . '/../vendor/autoload.php';
use App\Database;
use App\Controllers\ArticleController;
try {
$requestUri = $_SERVER['REQUEST_URI'];
$requestMethod = $_SERVER['REQUEST_METHOD'];
// Remove base path from URI
$basePath = '/api';
$path = parse_url($requestUri, PHP_URL_PATH);
$path = preg_replace('#^' . preg_quote($basePath, '#') . '#', '', $path);
$path = trim($path, '/');
// Parse path segments
$segments = $path ? explode('/', $path) : [];
$resource = $segments[0] ?? '';
$id = $segments[1] ?? null;
// Route to appropriate controller
$controller = match($resource) {
'articles' => new ArticleController(),
default => throw new Exception('Resource not found', 404),
};
$response = $controller->handle($requestMethod, $id);
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE, 512);
} catch (Exception $e) {
$code = $e->getCode() ?: 500;
http_response_code($code);
echo json_encode([
'error' => true,
'message' => $e->getMessage()
]);
}
This front controller parses the incoming URL, extracts the resource name and optional ID, then delegates to the appropriate controller. The try-catch block ensures any unexpected errors return a proper JSON error response instead of exposing raw PHP errors to clients. Using JSON_UNESCAPED_UNICODE ensures special characters display correctly regardless of language.
Handling JSON Input and Output
PHP's built-in json_encode and json_decode functions handle JSON serialisation for responses. For POST, PUT, and PATCH requests, you need to read the raw request body and decode it into a PHP array before processing.
<?php
// src/helpers.php
function getJsonInput(): array
{
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON in request body', 400);
}
return is_array($data) ? $data : [];
}
function jsonResponse(mixed $data, int $statusCode = 200): void
{
http_response_code($statusCode);
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
Always verify that json_decode returned an array. If the input is not valid JSON, json_decode returns null. If the input is a JSON primitive like a string or number, json_decode returns a scalar value rather than an array. Checking the return type prevents type errors when you try to access fields that do not exist.
If you are working with PHP 8.3 or later, you can use json_validate() before decoding to distinguish between invalid JSON and valid JSON that decodes to null. The PHP 8.3 changes guide covers this and other improvements that affect API development.
Creating the Article Controller
The controller handles HTTP method dispatching and delegates database operations to the model layer. Keeping controller logic thin and focused on request handling makes the code easier to test and modify as your API evolves.
<?php
// src/Controllers/ArticleController.php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\Article;
use Exception;
class ArticleController
{
private Article $articleModel;
public function __construct()
{
$this->articleModel = new Article();
}
public function handle(string $method, ?string $id): array
{
return match($method) {
'GET' => $this->handleGet($id),
'POST' => $this->handlePost(),
'PUT' => $this->handlePut($id),
'PATCH' => $this->handlePatch($id),
'DELETE' => $this->handleDelete($id),
default => throw new Exception('Method not allowed', 405),
};
}
private function handleGet(?string $id): array
{
if ($id !== null) {
$article = $this->articleModel->find($id);
if (!$article) {
throw new Exception('Article not found', 404);
}
return $article;
}
return $this->articleModel->all();
}
private function handlePost(): array
{
$input = getJsonInput();
$this->validateForCreate($input);
$id = $this->articleModel->create($input);
$article = $this->articleModel->find($id);
http_response_code(201);
return $article;
}
private function handlePut(?string $id): array
{
if ($id === null) {
throw new Exception('Resource ID required for PUT request', 400);
}
$input = getJsonInput();
$this->validateForUpdate($input);
$this->articleModel->replace($id, $input);
return $this->articleModel->find($id);
}
private function handlePatch(?string $id): array
{
if ($id === null) {
throw new Exception('Resource ID required for PATCH request', 400);
}
$input = getJsonInput();
$this->articleModel->update($id, $input);
return $this->articleModel->find($id);
}
private function handleDelete(?string $id): array
{
if ($id === null) {
throw new Exception('Resource ID required for DELETE request', 400);
}
$this->articleModel->delete($id);
http_response_code(204);
return [];
}
private function validateForCreate(array $input): void
{
$errors = [];
if (empty($input['title'])) {
$errors['title'] = 'Title is required';
} elseif (strlen($input['title']) > 255) {
$errors['title'] = 'Title must be 255 characters or less';
}
if (empty($input['content'])) {
$errors['content'] = 'Content is required';
}
if (!empty($errors)) {
throw new Exception(json_encode(['errors' => $errors]), 422);
}
}
private function validateForUpdate(array $input): void
{
$errors = [];
if (isset($input['title']) && strlen($input['title']) > 255) {
$errors['title'] = 'Title must be 255 characters or less';
}
if (!empty($errors)) {
throw new Exception(json_encode(['errors' => $errors]), 422);
}
}
}
The controller uses the match expression introduced in PHP 8.0, which provides a cleaner alternative to switch statements with automatic exception throwing if an unknown method is passed. Validation happens before any database operations, ensuring malformed requests never reach your model layer.
Building the Model Layer for Database Operations
The model layer handles all database operations. Separating database logic into a dedicated class makes it easier to swap out the database engine, add query optimisation, or modify your schema without touching your controller code.
<?php
// src/Models/Article.php
declare(strict_types=1);
namespace App\Models;
use App\Database;
use PDO;
class Article
{
private PDO $db;
public function __construct()
{
$this->db = Database::getConnection();
}
public function all(): array
{
$stmt = $this->db->query(
'SELECT id, title, content, created_at, updated_at
FROM articles
ORDER BY created_at DESC'
);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
public function find(string $id): ?array
{
$stmt = $this->db->prepare(
'SELECT id, title, content, created_at, updated_at
FROM articles
WHERE id = :id'
);
$stmt->execute(['id' => $id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ?: null;
}
public function create(array $data): string
{
$stmt = $this->db->prepare(
'INSERT INTO articles (title, content, created_at, updated_at)
VALUES (:title, :content, NOW(), NOW())'
);
$stmt->execute([
'title' => trim($data['title']),
'content' => trim($data['content']),
]);
return $this->db->lastInsertId();
}
public function update(string $id, array $data): bool
{
$allowedFields = ['title', 'content'];
$data = array_intersect_key($data, array_flip($allowedFields));
if (empty($data)) {
return false;
}
$set = implode(', ', array_map(
fn($key) => "$key = :$key",
array_keys($data)
));
$data['id'] = $id;
$stmt = $this->db->prepare(
"UPDATE articles SET $set, updated_at = NOW() WHERE id = :id"
);
$stmt->execute($data);
return $stmt->rowCount() > 0;
}
public function replace(string $id, array $data): bool
{
$stmt = $this->db->prepare(
'UPDATE articles
SET title = :title, content = :content, updated_at = NOW()
WHERE id = :id'
);
$stmt->execute([
'id' => $id,
'title' => trim($data['title'] ?? ''),
'content' => trim($data['content'] ?? ''),
]);
return $stmt->rowCount() > 0;
}
public function delete(string $id): bool
{
$stmt = $this->db->prepare('DELETE FROM articles WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->rowCount() > 0;
}
}
The update method uses array_intersect_key to whitelist allowed fields before processing. This prevents clients from sending arbitrary columns that might not exist in your schema or that should not be directly modified. The replace method handles PUT requests by requiring all fields and setting missing values to empty strings.
Setting Up the Database Connection
A reusable database connection class using the singleton pattern ensures you use the same connection throughout your application without repeatedly opening new connections for every query.
<?php
// src/Database.php
declare(strict_types=1);
namespace App;
use PDO;
use PDOException;
class Database
{
private static ?PDO $connection = null;
public static function getConnection(): PDO
{
if (self::$connection === null) {
$host = $_ENV['DB_HOST'] ?? 'localhost';
$dbname = $_ENV['DB_NAME'] ?? 'myapp';
$username = $_ENV['DB_USER'] ?? 'root';
$password = $_ENV['DB_PASSWORD'] ?? '';
try {
self::$connection = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
throw new Exception('Database connection failed: ' . $e->getMessage(), 500);
}
}
return self::$connection;
}
}
Using PDO with prepared statements protects against SQL injection attacks. Setting PDO::ATTR_EMULATE_PREPARES to false ensures prepared statements are used for all queries, which is the safest configuration. Store your database credentials in environment variables rather than hardcoding them in your source files.
Adding Simple Token Authentication
For APIs that should not be publicly accessible, implement token-based authentication. This example demonstrates a straightforward token validation approach. Production APIs typically use JWT (JSON Web Tokens) or OAuth 2.0 for more sophisticated authentication needs.
<?php
// src/middleware/AuthMiddleware.php
declare(strict_types=1);
namespace App\Middleware;
use App\Database;
class AuthMiddleware
{
public static function requireAuth(): array
{
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
throw new Exception('Authorization header missing or malformed', 401);
}
$token = $matches[1];
$user = self::validateToken($token);
if (!$user) {
throw new Exception('Invalid or expired token', 401);
}
return $user;
}
private static function validateToken(string $token): ?array
{
$db = Database::getConnection();
$stmt = $db->prepare(
'SELECT u.id, u.email, u.created_at
FROM api_tokens t
JOIN users u ON t.user_id = u.id
WHERE t.token = :token
AND t.expires_at > NOW()'
);
$stmt->execute(['token' => $token]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
}
To protect specific routes, call AuthMiddleware::requireAuth() at the beginning of the controller method that needs protection. The method returns user information that you can use for logging, rate limiting, or access control decisions. For a full authentication system, consider using a library like firebase/php-jwt for JWT handling.
Configuring URL Rewriting for the Front Controller
For the front controller pattern to work correctly, you need to rewrite all requests to public/index.php when a matching file or directory does not exist. On Apache, this requires an .htaccess file in your public directory with mod_rewrite enabled.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /api/
# Redirect trailing slashes
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [R=301,L]
# Handle front controller
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
For Nginx servers, use a similar location block in your server configuration:
location /api/ {
try_files $uri $uri/ /api/index.php?$query_string;
}
Test your URL rewriting configuration before deploying to production. Incorrect rewrite rules can cause redirect loops or return unexpected 404 errors for valid endpoints.
Testing Your REST API Endpoints
Once your API is running locally, test each endpoint with different request methods and payloads. cURL provides a reliable way to make manual requests during development:
# Create an article
curl -X POST http://localhost/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "My First Article", "content": "Article content here"}'
# Get all articles
curl http://localhost/api/articles
# Get a specific article
curl http://localhost/api/articles/1
# Update an article
curl -X PATCH http://localhost/api/articles/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}'
# Delete an article
curl -X DELETE http://localhost/api/articles/1
For more comprehensive testing during development, tools like Postman or Insomnia provide graphical interfaces for building and saving request collections. You can organise requests by resource and run entire test suites with a single click.
When debugging issues, setting up Xdebug with VS Code for PHP debugging lets you step through your code line by line, inspect variable values, and identify exactly where something is going wrong.
When to Move Beyond a Hand-Built API
A custom-built REST API is excellent for learning the fundamentals and for small projects with a limited number of endpoints. As your application grows, framework overhead becomes worthwhile because frameworks handle routing, middleware, validation, and security features consistently without requiring custom code for each concern.
Slim PHP is a lightweight framework suitable for APIs that need routing and middleware without Laravel's full feature set. Laravel offers a complete ecosystem with authentication scaffolding, database migrations, and API resource classes. Lumen, Laravel's micro-framework sibling, provides the same features optimised for speed.
If you need deeper understanding of API design principles before choosing a framework, a practical guide on API design for business applications covers patterns that apply regardless of which framework you eventually use.
REST vs GraphQL: Knowing When REST Makes Sense
REST APIs return fixed data structures defined by the server. If your frontend needs different fields for different views, you either maintain multiple endpoints or accept over-fetching data that the client discards. GraphQL lets clients specify exactly what fields they need, which can reduce payload sizes for complex UIs with varied data requirements.
For most business web applications with straightforward CRUD operations, REST provides sufficient flexibility without the additional complexity of a GraphQL schema layer, resolver functions, and subscription infrastructure. REST APIs are also easier to cache using standard HTTP caching mechanisms, which can improve performance for publicly accessible endpoints.
Securing Your PHP REST API
When exposing an API publicly, security considerations become critical. Several foundational practices protect your API against common attack vectors, though no single measure provides complete protection on its own.
- Use HTTPS exclusively: Encrypt all traffic between client and server to prevent token interception and man-in-the-middle attacks. Obtain an SSL certificate from a trusted certificate authority and configure automatic redirects from HTTP to HTTPS.
- Validate all input: Never trust data from the client. Validate type, length, format, and range for every input field before processing. Use allowlists where possible rather than blocklists for stricter validation.
- Rate limiting: Restrict how many requests a single client can make per minute to prevent abuse, brute-force attacks, and accidental denial of service from runaway scripts.
- Store tokens securely: If using session-based tokens, store them as hashed values in your database. Never log tokens or include them in error messages.
- Limit exposed information: Return minimal error details in production to avoid leaking implementation specifics. Detailed error information is useful for debugging but should only reach trusted clients or be disabled in live environments.
A security-focused approach to API development also considers access control. If your API serves multiple clients with different permission levels, implement role-based access control to ensure each client can only access resources intended for its access level.
Building secure PHP applications requires attention to input handling throughout your codebase. A security checklist for PHP applications covers additional measures beyond basic API security, including output encoding, database query safety, and session management.
Moving Forward with Your PHP REST API
Building a REST API from scratch in PHP gives you complete control over how requests are handled and responses are structured. The patterns covered here, from the front controller pattern to token authentication, transfer directly when you move to a framework or expand your API's capabilities.
The most important next step is testing. Build a simple client application that makes requests to your API, and verify that each endpoint behaves correctly with valid input, invalid input, and edge cases like empty strings, very long values, and special characters. Thorough testing during development prevents surprises when your API goes into production.
If your project grows beyond what a hand-built API can handle efficiently, or if you need features like automatic OpenAPI documentation, request throttling, or sophisticated caching, migrating to a framework like Slim or Laravel becomes straightforward because you already understand the underlying request handling patterns.