Modern PHP APIs power mobile apps, SPAs, and microservices. This guide covers advanced patterns beyond basic CRUD — authentication, versioning, validation, and documentation.

API Architecture Overview

  Client  →  Nginx  →  PHP-FPM  →  Router  →  Controller  →  Service  →  Repository  →  DB
                                      ↓
                              Middleware (auth, rate limit, CORS)
  

Separate concerns: controllers handle HTTP, services contain business logic, repositories handle data access.

RESTful Design Principles

Method Path Action
GET /api/v1/users List users
GET /api/v1/users/42 Get user 42
POST /api/v1/users Create user
PUT /api/v1/users/42 Replace user 42
PATCH /api/v1/users/42 Partial update
DELETE /api/v1/users/42 Delete user 42

Use proper HTTP status codes:

  // 200 OK, 201 Created, 204 No Content
// 400 Bad Request, 401 Unauthorized, 403 Forbidden
// 404 Not Found, 422 Unprocessable Entity, 429 Too Many Requests
// 500 Internal Server Error
  

Slim Framework Example

  <?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

require __DIR__ . '/vendor/autoload.php';

$app = AppFactory::create();
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

// CORS middleware
$app->add(function (Request $request, $handler) {
    $response = $handler->handle($request);
    return $response
        ->withHeader('Access-Control-Allow-Origin', '*')
        ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
});

$app->get('/api/v1/users/{id}', function (Request $request, Response $response, array $args) {
    $user = getUserById((int) $args['id']);

    if ($user === null) {
        $payload = json_encode(['error' => 'User not found']);
        $response->getBody()->write($payload);
        return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
    }

    $response->getBody()->write(json_encode(['data' => $user]));
    return $response->withHeader('Content-Type', 'application/json');
});

$app->run();
  

JWT Authentication

  use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function generateToken(int $userId, string $secret): string
{
    $payload = [
        'sub' => $userId,
        'iat' => time(),
        'exp' => time() + 3600,
    ];
    return JWT::encode($payload, $secret, 'HS256');
}

function authMiddleware(Request $request, $handler)
{
    $header = $request->getHeaderLine('Authorization');
    if (!str_starts_with($header, 'Bearer ')) {
        return new JsonResponse(['error' => 'Unauthorized'], 401);
    }

    $token = substr($header, 7);
    try {
        $decoded = JWT::decode($token, new Key(getenv('JWT_SECRET'), 'HS256'));
        $request = $request->withAttribute('userId', $decoded->sub);
        return $handler->handle($request);
    } catch (Exception $e) {
        return new JsonResponse(['error' => 'Invalid token'], 401);
    }
}
  

Input Validation

Never trust client input:

  function validateCreateUser(array $data): array
{
    $errors = [];

    if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'Valid email is required';
    }

    if (empty($data['name']) || strlen($data['name']) > 100) {
        $errors['name'] = 'Name is required (max 100 chars)';
    }

    if (!empty($errors)) {
        throw new ValidationException($errors);
    }

    return [
        'email' => strtolower(trim($data['email'])),
        'name'  => trim($data['name']),
    ];
}
  

Laravel Form Requests and Symfony Validator provide declarative validation.

API Versioning

URL versioning (most common):

  /api/v1/users
/api/v2/users
  

Header versioning:

  Accept: application/vnd.myapp.v2+json
  

Maintain v1 while building v2 — deprecate with sunset headers:

  $response = $response->withHeader('Deprecation', 'true')
                     ->withHeader('Sunset', 'Sat, 01 Jan 2026 00:00:00 GMT');
  

Rate Limiting

  function rateLimitMiddleware(Redis $redis, int $maxRequests = 100, int $windowSeconds = 60)
{
    return function (Request $request, $handler) use ($redis, $maxRequests, $windowSeconds) {
        $ip = $request->getServerParams()['REMOTE_ADDR'];
        $key = "rate:{$ip}:" . floor(time() / $windowSeconds);

        $count = $redis->incr($key);
        if ($count === 1) {
            $redis->expire($key, $windowSeconds);
        }

        if ($count > $maxRequests) {
            return new JsonResponse(['error' => 'Rate limit exceeded'], 429);
        }

        $response = $handler->handle($request);
        return $response->withHeader('X-RateLimit-Remaining', (string) ($maxRequests - $count));
    };
}
  

Consistent Response Format

  function jsonResponse(mixed $data, int $status = 200): Response
{
    $body = json_encode([
        'data' => $data,
        'meta' => [
            'timestamp' => date('c'),
        ],
    ], JSON_THROW_ON_ERROR);

    $response = new Response();
    $response->getBody()->write($body);
    return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
}

function jsonError(string $message, int $status, array $details = []): Response
{
    $body = json_encode([
        'error' => [
            'message' => $message,
            'details' => $details,
        ],
    ], JSON_THROW_ON_ERROR);

    $response = new Response();
    $response->getBody()->write($body);
    return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
}
  

Pagination

  $app->get('/api/v1/products', function (Request $request, Response $response) {
    $page = max(1, (int) ($request->getQueryParams()['page'] ?? 1));
    $perPage = min(100, max(1, (int) ($request->getQueryParams()['per_page'] ?? 20)));
    $offset = ($page - 1) * $perPage;

    $products = getProducts($offset, $perPage);
    $total = countProducts();

    $body = json_encode([
        'data' => $products,
        'meta' => [
            'page' => $page,
            'per_page' => $perPage,
            'total' => $total,
            'total_pages' => (int) ceil($total / $perPage),
        ],
    ]);

    $response->getBody()->write($body);
    return $response->withHeader('Content-Type', 'application/json');
});
  

OpenAPI Documentation

Document your API with OpenAPI 3.0 — tools like Swagger UI auto-generate interactive docs:

  openapi: 3.0.0
info:
  title: My API
  version: 1.0.0
paths:
  /api/v1/users/{id}:
    get:
      summary: Get user by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: User found
        '404':
          description: User not found
  

Laravel: darkaonline/l5-swagger. Symfony: nelmio/api-doc-bundle.

Security Checklist

  • HTTPS only — redirect HTTP, HSTS header
  • JWT with short expiry + refresh tokens
  • Input validation on every endpoint
  • SQL injection prevention — prepared statements / ORM
  • Rate limiting per IP and per user
  • CORS restricted to known origins in production
  • No sensitive data in error responses
  • API keys rotated regularly

Production PHP APIs rival any language’s capabilities — Laravel and Symfony provide enterprise-grade tooling when you need frameworks at scale.