What is a REST API and Why Build One in PHP?

A REST API (Representational State Transfer Application Programming Interface) allows different software systems to communicate over HTTP. Instead of building a graphical interface for every use case, you expose your application's data and functionality through a set of endpoints that other systems can consume. This makes it possible to connect web applications with mobile apps, third-party services, single-page applications, or internal tools.

PHP ships with everything you need to build a functional REST API. The language handles HTTP requests, JSON encoding and decoding, and server-side logic without requiring additional dependencies. Building an API from scratch in PHP gives you a clear understanding of how requests flow through a system, which makes debugging easier and helps you work more effectively with frameworks later.

REST API Conventions You Need to Know

REST is not a strict specification but a set of conventions that make APIs predictable. Understanding these conventions before writing code prevents common design mistakes.

HTTP Methods and Their Purpose

Each HTTP method carries specific meaning in a REST API:

  • GET: Retrieves data without modifying anything on the server. GET requests are safe to repeat and should not change resource state.
  • POST: Creates a new resource. Each POST request typically generates a new record.
  • PUT: Replaces a resource entirely. If a resource has three fields and you send two fields in a PUT request, the third field gets cleared.
  • PATCH: Partially updates a resource. Only the fields you send get modified.
  • DELETE: Removes a resource permanently.

URL Structure for REST APIs

REST URLs represent resources using nouns, not verbs. The HTTP method handles the action instead of embedding it in the URL path.

# 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

REST APIs return data in JSON format by default for modern applications. XML is still supported in some legacy systems but JSON has become the standard for new development.

Setting Up the Project Structure

A clean project structure makes maintenance easier as your API grows. For a basic PHP REST API, a simple directory layout works well:

project/
├── public/
│   └── index.php          # Front controller handling all requests
├── src/
│   ├── Controllers/
│   │   └── ArticleController.php
│   ├── Models/
│   │   └── Article.php
│   └── Database.php
├── .htaccess              # URL rewriting rules
└── composer.json

The public/index.php file acts as a single entry point for all API requests. This approach, called a front controller pattern, centralises routing logic and makes it easier to apply security measures uniformly.

Building the Front Controller and Router

The front controller receives every request and determines which controller and action should handle it. This separates request handling from business logic and keeps your code organised.

<?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.

Handling JSON Input and Output

PHP's built-in json_encode and json_decode functions handle JSON serialization. For POST, PUT, and PATCH requests, you need to read the raw request body and decode it into a PHP array.

<?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 validate that json_decode returned an array. If the input is not valid JSON, json_decode returns null, and if the input is a JSON primitive like a string or number, json_decode returns a scalar value rather than an array.

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.

<?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);
        }
    }
}

Building the Model Layer

The model layer handles database operations. Separating database logic into a dedicated class makes it easier to swap out the database engine or add query optimisation later 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;
    }
}

Setting Up the Database Connection

A reusable database connection class following the singleton pattern ensures you use the same connection throughout your application without repeatedly opening new connections.

<?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;
    }
}

Adding Simple Token Authentication

For APIs that should not be publicly accessible, implement token-based authentication. This example uses a simple 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. For a full authentication system, consider using a library like firebase/php-jwt for JWT handling.

Configuring URL Rewriting

For the front controller pattern to work, you need to rewrite all requests to public/index.php. On Apache, use an .htaccess file in the public directory:

<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, use a similar location block:

location /api/ {
    try_files $uri $uri/ /api/index.php?$query_string;
}

Testing Your REST API

Once your API is running, test each endpoint with different request methods and payloads. cURL is a reliable tool for manual testing:

# 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 a graphical interface for building and saving request collections.

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.

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. GraphQL lets clients specify exactly what fields they need, which can reduce payload sizes for complex UIs.

For most business web applications with straightforward CRUD operations, REST provides sufficient flexibility without the additional complexity of a GraphQL schema layer. If you want to compare approaches directly, an exploration of GraphQL in PHP and when it outperforms REST APIs explains the trade-offs in more detail.

Securing Your API

When exposing an API publicly, security considerations become critical. A few foundational practices protect your API against common attack vectors:

  • Use HTTPS exclusively: Encrypt all traffic between client and server to prevent token interception and man-in-the-middle attacks.
  • Validate all input: Never trust data from the client. Validate type, length, format, and range for every input field.
  • Rate limiting: Restrict how many requests a single client can make per minute to prevent abuse and brute-force attacks.
  • Store tokens securely: If using session-based tokens, store them as hashed values in your database.
  • Limit exposed information: Return minimal error details in production to avoid leaking implementation specifics.

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.

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, apply 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. 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 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.