TL;DR

Build RESTful APIs with PHP using proper HTTP methods, JSON responses, request routing, and input validation patterns.

Key concepts

  • PHP REST API
  • PHP API development
  • PHP JSON API
  • build API with PHP

API Development

APIs (Application Programming Interfaces) allow different applications to communicate with each other. REST APIs use HTTP methods and JSON to provide a standardized way for frontends, mobile apps, and other services to interact with your server. PHP is an excellent choice for building APIs, whether you use plain PHP, a micro-framework, or a full framework like Laravel. In this lesson you will learn the patterns and practices behind well-designed REST APIs.

What You'll Learn

  • REST API design principles and conventions
  • HTTP methods and status codes
  • Building JSON responses
  • Request routing for APIs
  • Input validation and error responses
  • Building a complete CRUD API
  • API versioning and best practices

REST Principles

REST (Representational State Transfer) uses standard HTTP methods to perform operations on resources. Each resource has a URL, and the HTTP method determines the action:

<?php

// REST API conventions:
// GET    /api/posts       - List all posts
// GET    /api/posts/1     - Get post #1
// POST   /api/posts       - Create a new post
// PUT    /api/posts/1     - Update post #1 (full replace)
// PATCH  /api/posts/1     - Partial update post #1
// DELETE /api/posts/1     - Delete post #1

// HTTP Status Codes for APIs
$statusCodes = [
    200 => 'OK - Successful request',
    201 => 'Created - Resource created successfully',
    204 => 'No Content - Successful deletion',
    400 => 'Bad Request - Invalid input',
    401 => 'Unauthorized - Authentication required',
    403 => 'Forbidden - Not allowed',
    404 => 'Not Found - Resource does not exist',
    422 => 'Unprocessable Entity - Validation failed',
    429 => 'Too Many Requests - Rate limited',
    500 => 'Internal Server Error',
];

echo "Common HTTP Status Codes for APIs:\n";
foreach ($statusCodes as $code => $description) {
    echo "  {$code}: {$description}\n";
}

echo "\nREST Resource Naming Conventions:\n";
$conventions = [
    '/api/users' => 'Collection (plural noun)',
    '/api/users/42' => 'Single resource by ID',
    '/api/users/42/posts' => 'Nested resource',
    '/api/posts?status=published' => 'Filtering with query params',
    '/api/posts?sort=created_at&order=desc' => 'Sorting',
    '/api/posts?page=2&per_page=10' => 'Pagination',
];

foreach ($conventions as $url => $description) {
    echo "  {$url} - {$description}\n";
}

Good API design uses plural nouns for resources, query parameters for filtering and pagination, and appropriate HTTP status codes for every response.

JSON Responses

APIs communicate with JSON. PHP makes it easy to encode data and set the proper headers:

<?php

class ApiResponse {
    public static function success(mixed $data, int $status = 200): array {
        return [
            'status' => $status,
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
        ];
    }

    public static function created(mixed $data, string $location = ''): array {
        $headers = ['Content-Type' => 'application/json'];
        if ($location) {
            $headers['Location'] = $location;
        }
        return [
            'status' => 201,
            'headers' => $headers,
            'body' => json_encode($data, JSON_PRETTY_PRINT),
        ];
    }

    public static function error(string $message, int $status = 400, array $errors = []): array {
        $body = ['error' => ['message' => $message, 'status' => $status]];
        if (!empty($errors)) {
            $body['error']['details'] = $errors;
        }
        return [
            'status' => $status,
            'headers' => ['Content-Type' => 'application/json'],
            'body' => json_encode($body, JSON_PRETTY_PRINT),
        ];
    }

    public static function paginated(array $items, int $total, int $page, int $perPage): array {
        $lastPage = (int) ceil($total / $perPage);
        return self::success([
            'data' => $items,
            'meta' => [
                'total' => $total,
                'page' => $page,
                'per_page' => $perPage,
                'last_page' => $lastPage,
                'has_more' => $page < $lastPage,
            ],
        ]);
    }
}

// Success response
$response = ApiResponse::success([
    'id' => 1,
    'name' => 'Alice',
    'email' => 'alice@example.com',
]);
echo "=== Success Response (HTTP {$response['status']}) ===\n";
echo $response['body'] . "\n\n";

// Created response
$response = ApiResponse::created(
    ['id' => 42, 'title' => 'New Post', 'status' => 'draft'],
    '/api/posts/42'
);
echo "=== Created Response (HTTP {$response['status']}) ===\n";
echo "Location: {$response['headers']['Location']}\n";
echo $response['body'] . "\n\n";

// Error response
$response = ApiResponse::error('Validation failed', 422, [
    'email' => 'Invalid email format',
    'name' => 'Name is required',
]);
echo "=== Error Response (HTTP {$response['status']}) ===\n";
echo $response['body'] . "\n\n";

// Paginated response
$posts = [
    ['id' => 1, 'title' => 'First Post'],
    ['id' => 2, 'title' => 'Second Post'],
];
$response = ApiResponse::paginated($posts, total: 25, page: 1, perPage: 10);
echo "=== Paginated Response ===\n";
echo $response['body'] . "\n";

Consistent response structure is critical for API consumers. Always include proper status codes, use a standard envelope format (like data and meta fields), and return structured error messages with enough detail for clients to fix issues.

Input Validation

Never trust client input. Validate every field before processing:

<?php

class Validator {
    private array $errors = [];

    public function validate(array $data, array $rules): bool {
        $this->errors = [];

        foreach ($rules as $field => $fieldRules) {
            $value = $data[$field] ?? null;

            foreach ($fieldRules as $rule) {
                $error = $this->checkRule($field, $value, $rule);
                if ($error !== null) {
                    $this->errors[$field][] = $error;
                }
            }
        }

        return empty($this->errors);
    }

    private function checkRule(string $field, mixed $value, string $rule): ?string {
        if ($rule === 'required' && (is_null($value) || $value === '')) {
            return "{$field} is required";
        }

        if ($rule === 'email' && $value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
            return "{$field} must be a valid email address";
        }

        if (str_starts_with($rule, 'min:')) {
            $min = (int) substr($rule, 4);
            if (is_string($value) && strlen($value) < $min) {
                return "{$field} must be at least {$min} characters";
            }
        }

        if (str_starts_with($rule, 'max:')) {
            $max = (int) substr($rule, 4);
            if (is_string($value) && strlen($value) > $max) {
                return "{$field} must not exceed {$max} characters";
            }
        }

        if (str_starts_with($rule, 'in:')) {
            $allowed = explode(',', substr($rule, 3));
            if ($value && !in_array($value, $allowed)) {
                return "{$field} must be one of: " . implode(', ', $allowed);
            }
        }

        return null;
    }

    public function getErrors(): array {
        return $this->errors;
    }
}

// Define validation rules
$rules = [
    'title' => ['required', 'min:3', 'max:100'],
    'body' => ['required', 'min:10'],
    'status' => ['required', 'in:draft,published,archived'],
    'author_email' => ['required', 'email'],
];

$validator = new Validator();

// Valid input
$validData = [
    'title' => 'My Great Post',
    'body' => 'This is the content of my blog post with plenty of detail.',
    'status' => 'published',
    'author_email' => 'alice@example.com',
];

echo "=== Valid Input ===\n";
if ($validator->validate($validData, $rules)) {
    echo "Validation passed!\n";
} else {
    echo "Errors: " . json_encode($validator->getErrors(), JSON_PRETTY_PRINT) . "\n";
}

// Invalid input
$invalidData = [
    'title' => 'Hi',
    'body' => 'Short',
    'status' => 'unknown',
    'author_email' => 'not-an-email',
];

echo "\n=== Invalid Input ===\n";
if ($validator->validate($invalidData, $rules)) {
    echo "Validation passed!\n";
} else {
    foreach ($validator->getErrors() as $field => $fieldErrors) {
        foreach ($fieldErrors as $error) {
            echo "  - {$error}\n";
        }
    }
}

Laravel provides a much more powerful validation system with dozens of built-in rules. The concept is the same: define rules for each field, validate the input, and return clear error messages for any violations.

Building a CRUD API

Here is a complete API controller demonstrating all CRUD operations with validation and error handling:

<?php

class PostApiController {
    private array $posts = [];
    private int $nextId = 1;

    public function __construct() {
        // Seed some initial data
        $this->posts[1] = ['id' => 1, 'title' => 'Getting Started with PHP APIs', 'body' => 'Learn how to build REST APIs...', 'status' => 'published', 'created_at' => '2024-01-15'];
        $this->posts[2] = ['id' => 2, 'title' => 'Database Best Practices', 'body' => 'Tips for working with databases...', 'status' => 'published', 'created_at' => '2024-01-20'];
        $this->posts[3] = ['id' => 3, 'title' => 'Draft Ideas', 'body' => 'Some ideas for future posts...', 'status' => 'draft', 'created_at' => '2024-02-01'];
        $this->nextId = 4;
    }

    // GET /api/posts
    public function index(array $query = []): array {
        $posts = array_values($this->posts);

        // Filter by status
        if (isset($query['status'])) {
            $posts = array_filter($posts, fn($p) => $p['status'] === $query['status']);
            $posts = array_values($posts);
        }

        return ['status' => 200, 'data' => $posts, 'total' => count($posts)];
    }

    // GET /api/posts/{id}
    public function show(int $id): array {
        if (!isset($this->posts[$id])) {
            return ['status' => 404, 'error' => "Post #{$id} not found"];
        }
        return ['status' => 200, 'data' => $this->posts[$id]];
    }

    // POST /api/posts
    public function store(array $data): array {
        $errors = $this->validate($data);
        if (!empty($errors)) {
            return ['status' => 422, 'error' => 'Validation failed', 'details' => $errors];
        }

        $id = $this->nextId++;
        $this->posts[$id] = [
            'id' => $id,
            'title' => $data['title'],
            'body' => $data['body'],
            'status' => $data['status'] ?? 'draft',
            'created_at' => date('Y-m-d'),
        ];

        return ['status' => 201, 'data' => $this->posts[$id]];
    }

    // PUT /api/posts/{id}
    public function update(int $id, array $data): array {
        if (!isset($this->posts[$id])) {
            return ['status' => 404, 'error' => "Post #{$id} not found"];
        }

        $errors = $this->validate($data);
        if (!empty($errors)) {
            return ['status' => 422, 'error' => 'Validation failed', 'details' => $errors];
        }

        $this->posts[$id] = array_merge($this->posts[$id], [
            'title' => $data['title'],
            'body' => $data['body'],
            'status' => $data['status'] ?? $this->posts[$id]['status'],
        ]);

        return ['status' => 200, 'data' => $this->posts[$id]];
    }

    // DELETE /api/posts/{id}
    public function destroy(int $id): array {
        if (!isset($this->posts[$id])) {
            return ['status' => 404, 'error' => "Post #{$id} not found"];
        }
        unset($this->posts[$id]);
        return ['status' => 204, 'data' => null];
    }

    private function validate(array $data): array {
        $errors = [];
        if (empty($data['title'])) $errors['title'] = 'Title is required';
        if (empty($data['body'])) $errors['body'] = 'Body is required';
        if (isset($data['status']) && !in_array($data['status'], ['draft', 'published'])) {
            $errors['status'] = 'Status must be draft or published';
        }
        return $errors;
    }
}

$api = new PostApiController();

// List all posts
echo "=== GET /api/posts ===\n";
$result = $api->index();
echo json_encode($result, JSON_PRETTY_PRINT) . "\n\n";

// Filter published posts
echo "=== GET /api/posts?status=published ===\n";
$result = $api->index(['status' => 'published']);
echo "Found {$result['total']} published posts\n\n";

// Get single post
echo "=== GET /api/posts/1 ===\n";
$result = $api->show(1);
echo "Title: {$result['data']['title']}\n\n";

// Create post
echo "=== POST /api/posts ===\n";
$result = $api->store(['title' => 'New API Post', 'body' => 'Created via API', 'status' => 'published']);
echo "Created: [{$result['status']}] ID #{$result['data']['id']}: {$result['data']['title']}\n\n";

// Update post
echo "=== PUT /api/posts/2 ===\n";
$result = $api->update(2, ['title' => 'Updated Title', 'body' => 'Updated content']);
echo "Updated: {$result['data']['title']}\n\n";

// Delete post
echo "=== DELETE /api/posts/3 ===\n";
$result = $api->destroy(3);
echo "Deleted: HTTP {$result['status']}\n\n";

// 404 example
echo "=== GET /api/posts/999 ===\n";
$result = $api->show(999);
echo "Error: {$result['error']}\n";

This pattern -- validate input, perform the operation, return a structured response -- is the core of API development. Each method maps to an HTTP verb and returns the appropriate status code.

Key Takeaways

  • REST APIs use HTTP methods (GET, POST, PUT, PATCH, DELETE) as verbs and URLs as nouns
  • Always return JSON with the Content-Type: application/json header
  • Use appropriate HTTP status codes: 200 for success, 201 for created, 404 for not found, 422 for validation errors
  • Validate all input before processing and return clear error messages
  • Use consistent response envelopes with data, meta, and error fields
  • Paginate list endpoints to prevent large responses
  • Version your API (e.g., /api/v1/posts) to allow breaking changes without affecting existing clients
  • Never expose internal implementation details in error responses

Next Steps

You can now build complete CRUD APIs with proper HTTP methods, status codes, input validation, and structured JSON responses. Your PHP applications can serve data to any client.

An API that anyone can access is a security risk. The moment your application handles user data -- accounts, passwords, personal information -- you need to protect it. Authentication and security covers password hashing with bcrypt, CSRF protection, XSS prevention, SQL injection defense, and session-based login flows. These are not optional extras; they are requirements for any application that goes to production, and understanding them deeply will make you a more trusted and employable developer.

Continue to Authentication and Security -->

Pro Tip: Use API documentation tools like OpenAPI (Swagger) to define your API endpoints, request bodies, and responses. This creates a contract between your API and its consumers, and tools can auto-generate client libraries and interactive documentation from the specification. In Laravel, packages like scribe or l5-swagger generate API docs automatically from your route and controller definitions.