How to Build a REST API in PHP from Scratch

18 min read 3,584 words
How to Build a Simple REST API in PHP featured image

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.

Frequently Asked Questions

Do I need a framework to build a REST API in PHP?
No. PHP's built-in features handle HTTP requests, JSON processing, and database access without additional libraries. Starting without a framework teaches you how these components work internally, which makes framework code easier to debug and extend when you decide to adopt one. A framework becomes valuable when your API grows to dozens of endpoints, requires complex authentication flows, or needs built-in features like automatic rate limiting and request logging.
What database should I use with a PHP REST API?
MySQL and PostgreSQL are the most common choices for PHP REST APIs in the UK and globally. MySQL works well for straightforward CRUD applications with typical data volumes. PostgreSQL offers more advanced features like JSONB columns for semi-structured data, full-text search capabilities, and better support for complex data types. PDO, PHP's database abstraction layer, lets you write database-agnostic code that can switch between database engines with minimal changes if your requirements evolve.
How do I handle authentication in a REST API?
Token-based authentication is the standard approach for REST APIs. The server issues a token after verifying credentials, and the client includes that token in subsequent requests using an Authorization header. Simple API tokens stored as hashed values in your database work for basic use cases.
What HTTP status codes should a REST API return?
Use 200 for successful GET, PUT, and PATCH requests. Return 201 for successful POST requests that create a resource, along with a Location header pointing to the new resource. Use 204 for successful DELETE requests where no response body is needed. For client errors, return 400 for malformed requests, 401 for missing authentication, 403 for authenticated but unauthorised access, 404 for resources that do not exist, and 422 for validation errors on the submitted data.
How do I prevent CORS issues when calling my API from a frontend application?
CORS (Cross-Origin Resource Sharing) restrictions occur when your frontend runs on a different domain than your API. Browsers block these requests by default for security reasons. Your API front controller should include CORS headers that whitelist allowed origins. For development, allowing all origins with Access-Control-Allow-Origin: * works, but production APIs should specify exact domains. Handle preflight OPTIONS requests by returning a 204 status with appropriate headers before your main request handling logic runs.
Can I use this approach for webhook integrations?
Yes. Webhook receivers are essentially POST endpoints that accept data from external services. The same JSON parsing and validation logic covered here applies. When implementing webhooks, verify the sender's identity using signature verification if the service provides it, and implement idempotency checks to handle duplicate deliveries gracefully.