Building Scalable Backend APIs with Modern PHP

Architectural patterns and best practices for creating robust, scalable backend systems using modern PHP.

Building scalable APIs is about more than just handling high traffic—it's about creating systems that can grow with your business while maintaining performance, reliability, and maintainability. Modern PHP provides excellent tools for building enterprise-grade APIs that can handle millions of requests.

This article covers architectural patterns, design principles, and implementation strategies I've used to build APIs that scale from thousands to millions of users.

API Architecture Principles

Layered Architecture

Separate concerns into distinct layers for better maintainability and testability:

<?php
declare(strict_types=1);
namespace AppHttpControllers;
use AppServicesUserUserService;
use AppHttp{Request, Response, JsonResponse};
use AppExceptions{ValidationException, DuplicateEmailException};
use AppValueObjectsUserId;
use PsrLogLoggerInterface;
// Controller Layer - HTTP concerns only
final readonly class UserController
{
public function __construct(
private UserService $userService,
private LoggerInterface $logger,
) {}
public function createUser(Request $request): Response
{
$userData = $request->getValidatedData();
try {
$user = $this->userService->createUser($userData);
return new JsonResponse([
'id' => $user->getId()->value,
'email' => $user->getEmail()->value,
'name' => $user->getName()->value,
'created_at' => $user->getCreatedAt()->format('c'),
], 201);
} catch (ValidationException $e) {
return new JsonResponse([
'error' => 'Validation failed',
'violations' => $e->getViolations(),
], 400);
} catch (DuplicateEmailException $e) {
return new JsonResponse([
'error' => 'Email already exists',
'code' => 'DUPLICATE_EMAIL',
], 409);
}
}
}
// Service Layer - Business logic
final readonly class UserService
{
public function __construct(
private UserRepository $userRepository,
private EmailService $emailService,
private EventDispatcher $eventDispatcher,
private UserValidator $validator,
private PasswordHasher $passwordHasher,
) {}
public function createUser(array $userData): User
{
$this->validator->validate($userData);
$user = User::create(
UserId::generate(),
EmailAddress::fromString($userData['email']),
UserName::fromString($userData['name']),
$this->passwordHasher->hash($userData['password'])
);
$this->userRepository->save($user);
$this->emailService->sendWelcomeEmail($user);
$this->eventDispatcher->dispatch(
new UserCreatedEvent($user->getId(), $user->getEmail())
);
return $user;
}
}
// Repository Layer - Data access
final readonly class UserRepository
{
public function __construct(
private PDO $connection,
private UserHydrator $hydrator,
) {}
public function save(User $user): void
{
$stmt = $this->connection->prepare(<<< 'SQL'
INSERT INTO users (id, email, name, password_hash, created_at)
VALUES (:id, :email, :name, :password_hash, :created_at)
SQL);
$stmt->execute([
'id' => $user->getId()->value,
'email' => $user->getEmail()->value,
'name' => $user->getName()->value,
'password_hash' => $user->getPasswordHash()->value,
'created_at' => $user->getCreatedAt()->format('Y-m-d H:i:s')
]);
}
public function findById(UserId $id): ?User
{
$stmt = $this->connection->prepare(<<< 'SQL'
SELECT id, email, name, password_hash, created_at
FROM users
WHERE id = :id AND deleted_at IS NULL
SQL);
$stmt->execute(['id' => $id->value]);
$userData = $stmt->fetch();
return $userData ? $this->hydrator->hydrate($userData) : null;
}
}

Domain-Driven Design

Model your business domain explicitly:

<?php
declare(strict_types=1);
namespace AppDomainUser;
use AppValueObjects{UserId, EmailAddress, UserName, PasswordHash};
use AppExceptions{UserAlreadyDeactivatedException, InvalidStateTransitionException};
use AppDomain{AggregateRoot, DomainEvent};
use DateTimeImmutable;
// Domain Entity
final class User extends AggregateRoot
{
private function __construct(
private readonly UserId $id,
private EmailAddress $email,
private readonly UserName $name,
private readonly PasswordHash $passwordHash,
private UserStatus $status,
private readonly DateTimeImmutable $createdAt,
) {}
public static function create(
UserId $id,
EmailAddress $email,
UserName $name,
PasswordHash $passwordHash
): self {
$user = new self(
$id,
$email,
$name,
$passwordHash,
UserStatus::ACTIVE,
new DateTimeImmutable()
);
$user->recordEvent(new UserCreatedEvent($id, $email));
return $user;
}
public function changeEmail(EmailAddress $newEmail): void
{
if ($this->email->equals($newEmail)) {
return;
}
$previousEmail = $this->email;
$this->email = $newEmail;
$this->recordEvent(new UserEmailChangedEvent(
$this->id,
$previousEmail,
$newEmail
));
}
public function deactivate(): void
{
if ($this->status === UserStatus::DEACTIVATED) {
throw new UserAlreadyDeactivatedException(
"User {$this->id->value} is already deactivated"
);
}
$this->status = UserStatus::DEACTIVATED;
$this->recordEvent(new UserDeactivatedEvent($this->id));
}
public function activate(): void
{
if ($this->status === UserStatus::SUSPENDED) {
throw new InvalidStateTransitionException(
"Cannot activate suspended user {$this->id->value}"
);
}
$this->status = UserStatus::ACTIVE;
$this->recordEvent(new UserActivatedEvent($this->id));
}
public function isActive(): bool
{
return $this->status === UserStatus::ACTIVE;
}
public function getId(): UserId { return $this->id; }
public function getEmail(): EmailAddress { return $this->email; }
public function getName(): UserName { return $this->name; }
public function getPasswordHash(): PasswordHash { return $this->passwordHash; }
public function getStatus(): UserStatus { return $this->status; }
public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; }
}
// Value Object
enum UserStatus: string {
case ACTIVE = 'active';
case DEACTIVATED = 'deactivated';
case SUSPENDED = 'suspended';
public function canTransitionTo(self $newStatus): bool
{
return match ([$this, $newStatus]) {
[self::ACTIVE, self::DEACTIVATED] => true,
[self::ACTIVE, self::SUSPENDED] => true,
[self::DEACTIVATED, self::ACTIVE] => true,
[self::SUSPENDED, self::DEACTIVATED] => true,
default => false,
};
}
}
// Domain Service
final readonly class UserDomainService
{
public function canUserAccessResource(User $user, Resource $resource): bool
{
if (!$user->isActive()) {
return false;
}
if ($resource->requiresPremium() && !$user->isPremium()) {
return false;
}
return $user->hasPermission($resource->getRequiredPermission());
}
public function canUserPerformAction(User $user, Action $action): bool
{
return match ($user->getStatus()) {
UserStatus::ACTIVE => true,
UserStatus::SUSPENDED => $action->isAllowedForSuspendedUsers(),
UserStatus::DEACTIVATED => false,
};
}
}

API Design Patterns

CQRS (Command Query Responsibility Segregation)

Separate read and write operations for better scalability:

<?php
// Command Handler - Write operations
class CreateUserCommandHandler {
private UserRepository $userRepository;
private EventStore $eventStore;
public function handle(CreateUserCommand $command): void {
$user = new User($command->email, $command->name);
$user->setPassword(password_hash($command->password, PASSWORD_DEFAULT));
// Save to write database
$this->userRepository->save($user);
// Store event for read model updates
$event = new UserCreatedEvent($user->getId(), $user->getEmail(), $user->getName());
$this->eventStore->store($event);
}
}
// Query Handler - Read operations
class GetUserQueryHandler {
private UserReadModel $userReadModel;
public function handle(GetUserQuery $query): UserView {
// Read from optimized read model
return $this->userReadModel->getUserById($query->userId);
}
}
// Read Model - Optimized for queries
class UserReadModel {
private Redis $redis;
private PDO $readDb;
public function getUserById(int $userId): UserView {
// Try cache first
$cached = $this->redis->get("user:$userId");
if ($cached) {
return unserialize($cached);
}
// Read from database
$sql = "SELECT u.*, p.name as profile_name, p.avatar_url
FROM users u
LEFT JOIN profiles p ON u.id = p.user_id
WHERE u.id = :id";
$stmt = $this->readDb->prepare($sql);
$stmt->execute(['id' => $userId]);
$userData = $stmt->fetch();
$userView = new UserView($userData);
// Cache for future requests
$this->redis->setex("user:$userId", 3600, serialize($userView));
return $userView;
}
}

Event-Driven Architecture

Decouple components using events:

<?php
// Event System
class EventDispatcher {
private array $listeners = [];
public function subscribe(string $eventClass, callable $listener): void {
$this->listeners[$eventClass][] = $listener;
}
public function dispatch(object $event): void {
$eventClass = get_class($event);
if (isset($this->listeners[$eventClass])) {
foreach ($this->listeners[$eventClass] as $listener) {
$listener($event);
}
}
}
}
// Event
class UserCreatedEvent {
public function __construct(
public readonly int $userId,
public readonly string $email,
public readonly string $name,
public readonly DateTimeImmutable $occurredAt = new DateTimeImmutable()
) {}
}
// Event Listeners
class SendWelcomeEmailListener {
private EmailService $emailService;
public function __invoke(UserCreatedEvent $event): void {
$this->emailService->sendWelcomeEmail($event->email, $event->name);
}
}
class UpdateUserStatsListener {
private UserStatsService $userStatsService;
public function __invoke(UserCreatedEvent $event): void {
$this->userStatsService->incrementUserCount();
}
}
// Event Registration
$eventDispatcher = new EventDispatcher();
$eventDispatcher->subscribe(UserCreatedEvent::class, new SendWelcomeEmailListener($emailService));
$eventDispatcher->subscribe(UserCreatedEvent::class, new UpdateUserStatsListener($userStatsService));

Performance Optimization

Database Connection Pooling

<?php
class DatabasePool {
private array $connections = [];
private array $config;
private int $maxConnections;
private int $currentConnections = 0;
public function __construct(array $config, int $maxConnections = 20) {
$this->config = $config;
$this->maxConnections = $maxConnections;
}
public function getConnection(): PDO {
// Return existing connection if available
if (!empty($this->connections)) {
return array_pop($this->connections);
}
// Create new connection if under limit
if ($this->currentConnections < $this->maxConnections) {
$connection = new PDO(
$this->config['dsn'],
$this->config['username'],
$this->config['password'],
[
PDO::ATTR_PERSISTENT => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$this->currentConnections++;
return $connection;
}
// Wait for available connection
usleep(10000); // 10ms
return $this->getConnection();
}
public function releaseConnection(PDO $connection): void {
// Reset connection state
$connection->rollBack();
$connection->exec('SET autocommit = 1');
// Return to pool
$this->connections[] = $connection;
}
}

Response Caching

<?php
class ResponseCache {
private Redis $redis;
private int $defaultTtl = 3600;
public function __construct(Redis $redis) {
$this->redis = $redis;
}
public function get(Request $request): ?Response {
$key = $this->generateCacheKey($request);
$cached = $this->redis->get($key);
if ($cached) {
$data = json_decode($cached, true);
return new Response($data['body'], $data['status'], $data['headers']);
}
return null;
}
public function set(Request $request, Response $response, int $ttl = null): void {
$key = $this->generateCacheKey($request);
$ttl = $ttl ?? $this->defaultTtl;
$data = [
'body' => $response->getBody(),
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'cached_at' => time()
];
$this->redis->setex($key, $ttl, json_encode($data));
}
private function generateCacheKey(Request $request): string {
$components = [
$request->getMethod(),
$request->getUri(),
$request->getQueryParams(),
$request->getHeader('Accept'),
$request->getHeader('Authorization') ? 'auth' : 'public'
];
return 'response:' . md5(serialize($components));
}
}

Rate Limiting and Throttling

Token Bucket Algorithm

<?php
class TokenBucketRateLimiter {
private Redis $redis;
private int $capacity;
private int $refillRate;
private int $refillPeriod;
public function __construct(Redis $redis, int $capacity = 100, int $refillRate = 10, int $refillPeriod = 60) {
$this->redis = $redis;
$this->capacity = $capacity;
$this->refillRate = $refillRate;
$this->refillPeriod = $refillPeriod;
}
public function isAllowed(string $identifier): bool {
$key = "rate_limit:$identifier";
$now = time();
// Get current bucket state
$bucketData = $this->redis->hmget($key, ['tokens', 'last_refill']);
$tokens = $bucketData['tokens'] ?? $this->capacity;
$lastRefill = $bucketData['last_refill'] ?? $now;
// Calculate tokens to add
$timePassed = $now - $lastRefill;
$tokensToAdd = floor($timePassed / $this->refillPeriod) * $this->refillRate;
$tokens = min($this->capacity, $tokens + $tokensToAdd);
// Check if request is allowed
if ($tokens >= 1) {
$tokens--;
// Update bucket state
$this->redis->hmset($key, [
'tokens' => $tokens,
'last_refill' => $now
]);
$this->redis->expire($key, $this->refillPeriod * 2);
return true;
}
return false;
}
public function getRemainingTokens(string $identifier): int {
$key = "rate_limit:$identifier";
$bucketData = $this->redis->hmget($key, ['tokens']);
return $bucketData['tokens'] ?? $this->capacity;
}
}

Sliding Window Rate Limiter

<?php
class SlidingWindowRateLimiter {
private Redis $redis;
private int $limit;
private int $windowSize;
public function __construct(Redis $redis, int $limit = 1000, int $windowSize = 3600) {
$this->redis = $redis;
$this->limit = $limit;
$this->windowSize = $windowSize;
}
public function isAllowed(string $identifier): bool {
$key = "sliding_window:$identifier";
$now = time();
$windowStart = $now - $this->windowSize;
// Remove old entries
$this->redis->zremrangebyscore($key, 0, $windowStart);
// Count current requests
$currentCount = $this->redis->zcard($key);
if ($currentCount < $this->limit) {
// Add current request
$this->redis->zadd($key, $now, uniqid());
$this->redis->expire($key, $this->windowSize);
return true;
}
return false;
}
public function getRemainingRequests(string $identifier): int {
$key = "sliding_window:$identifier";
$now = time();
$windowStart = $now - $this->windowSize;
$this->redis->zremrangebyscore($key, 0, $windowStart);
$currentCount = $this->redis->zcard($key);
return max(0, $this->limit - $currentCount);
}
}

Error Handling and Resilience

Circuit Breaker Pattern

<?php
class CircuitBreaker {
private Redis $redis;
private int $failureThreshold;
private int $recoveryTimeout;
private int $monitoringPeriod;
public function __construct(Redis $redis, int $failureThreshold = 5, int $recoveryTimeout = 300, int $monitoringPeriod = 60) {
$this->redis = $redis;
$this->failureThreshold = $failureThreshold;
$this->recoveryTimeout = $recoveryTimeout;
$this->monitoringPeriod = $monitoringPeriod;
}
public function call(string $service, callable $operation) {
$state = $this->getState($service);
switch ($state) {
case 'open':
if ($this->shouldAttemptReset($service)) {
$this->setState($service, 'half-open');
return $this->executeOperation($service, $operation);
}
throw new CircuitBreakerOpenException("Circuit breaker is open for $service");
case 'half-open':
return $this->executeOperation($service, $operation);
case 'closed':
default:
return $this->executeOperation($service, $operation);
}
}
private function executeOperation(string $service, callable $operation) {
try {
$result = $operation();
$this->recordSuccess($service);
return $result;
} catch (Exception $e) {
$this->recordFailure($service);
throw $e;
}
}
private function recordSuccess(string $service): void {
$key = "circuit_breaker:$service";
$this->redis->hdel($key, 'failures');
$this->setState($service, 'closed');
}
private function recordFailure(string $service): void {
$key = "circuit_breaker:$service";
$failures = $this->redis->hincrby($key, 'failures', 1);
$this->redis->expire($key, $this->monitoringPeriod);
if ($failures >= $this->failureThreshold) {
$this->setState($service, 'open');
}
}
private function getState(string $service): string {
$key = "circuit_breaker:$service";
return $this->redis->hget($key, 'state') ?: 'closed';
}
private function setState(string $service, string $state): void {
$key = "circuit_breaker:$service";
$this->redis->hset($key, 'state', $state);
if ($state === 'open') {
$this->redis->hset($key, 'opened_at', time());
}
}
private function shouldAttemptReset(string $service): bool {
$key = "circuit_breaker:$service";
$openedAt = $this->redis->hget($key, 'opened_at');
return $openedAt && (time() - $openedAt) > $this->recoveryTimeout;
}
}

API Security

JWT Authentication

<?php
class JWTManager {
private string $secretKey;
private string $algorithm = 'HS256';
private int $defaultTtl = 3600;
public function __construct(string $secretKey) {
$this->secretKey = $secretKey;
}
public function generateToken(array $payload, int $ttl = null): string {
$ttl = $ttl ?? $this->defaultTtl;
$now = time();
$header = json_encode(['typ' => 'JWT', 'alg' => $this->algorithm]);
$payload = json_encode(array_merge($payload, [
'iat' => $now,
'exp' => $now + $ttl
]));
$headerPayload = $this->base64UrlEncode($header) . '.' . $this->base64UrlEncode($payload);
$signature = $this->sign($headerPayload);
return $headerPayload . '.' . $signature;
}
public function validateToken(string $token): array {
$parts = explode('.', $token);
if (count($parts) !== 3) {
throw new InvalidTokenException('Invalid token format');
}
[$header, $payload, $signature] = $parts;
// Verify signature
$expectedSignature = $this->sign($header . '.' . $payload);
if (!hash_equals($signature, $expectedSignature)) {
throw new InvalidTokenException('Invalid signature');
}
// Decode payload
$decodedPayload = json_decode($this->base64UrlDecode($payload), true);
// Check expiration
if (isset($decodedPayload['exp']) && $decodedPayload['exp'] < time()) {
throw new ExpiredTokenException('Token has expired');
}
return $decodedPayload;
}
private function sign(string $data): string {
return $this->base64UrlEncode(hash_hmac('sha256', $data, $this->secretKey, true));
}
private function base64UrlEncode(string $data): string {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function base64UrlDecode(string $data): string {
return base64_decode(strtr($data, '-_', '+/'));
}
}

API Documentation and Versioning

OpenAPI Documentation

<?php
class OpenAPIGenerator {
private array $paths = [];
private array $components = [];
public function addEndpoint(string $path, string $method, array $definition): void {
$this->paths[$path][$method] = $definition;
}
public function addComponent(string $name, array $schema): void {
$this->components['schemas'][$name] = $schema;
}
public function generate(): array {
return [
'openapi' => '3.0.0',
'info' => [
'title' => 'API Documentation',
'version' => '1.0.0',
'description' => 'Scalable PHP API'
],
'servers' => [
['url' => 'https://api.example.com/v1']
],
'paths' => $this->paths,
'components' => $this->components
];
}
public function generateFromAnnotations(): array {
$reflection = new ReflectionClass(UserController::class);
$methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
$docComment = $method->getDocComment();
if ($docComment) {
$this->parseDocComment($docComment, $method);
}
}
return $this->generate();
}
private function parseDocComment(string $docComment, ReflectionMethod $method): void {
// Parse PHPDoc annotations for OpenAPI spec
if (preg_match('/@Route("([^"]+)".*method="([^"]+)")/', $docComment, $matches)) {
$path = $matches[1];
$httpMethod = strtolower($matches[2]);
// Extract other annotations
$summary = $this->extractAnnotation($docComment, 'summary');
$description = $this->extractAnnotation($docComment, 'description');
$this->addEndpoint($path, $httpMethod, [
'summary' => $summary,
'description' => $description,
'operationId' => $method->getName()
]);
}
}
private function extractAnnotation(string $docComment, string $annotation): ?string {
if (preg_match("/@{$annotation}s+(.+)/", $docComment, $matches)) {
return trim($matches[1]);
}
return null;
}
}

Monitoring and Observability

Metrics Collection

<?php
class MetricsCollector {
private Redis $redis;
private array $metrics = [];
public function __construct(Redis $redis) {
$this->redis = $redis;
}
public function increment(string $metric, int $value = 1, array $tags = []): void {
$key = $this->buildKey($metric, $tags);
$this->redis->incrby($key, $value);
$this->redis->expire($key, 3600);
}
public function gauge(string $metric, float $value, array $tags = []): void {
$key = $this->buildKey($metric, $tags);
$this->redis->set($key, $value);
$this->redis->expire($key, 3600);
}
public function timing(string $metric, float $duration, array $tags = []): void {
$key = $this->buildKey($metric . '.timing', $tags);
$this->redis->lpush($key, $duration);
$this->redis->ltrim($key, 0, 999); // Keep last 1000 measurements
$this->redis->expire($key, 3600);
}
public function histogram(string $metric, float $value, array $tags = []): void {
$buckets = [0.1, 0.5, 1, 2.5, 5, 10];
foreach ($buckets as $bucket) {
if ($value <= $bucket) {
$key = $this->buildKey($metric . '.bucket', array_merge($tags, ['le' => $bucket]));
$this->redis->incr($key);
$this->redis->expire($key, 3600);
}
}
}
private function buildKey(string $metric, array $tags): string {
$tagString = '';
if (!empty($tags)) {
ksort($tags);
$tagString = ':' . implode(':', array_map(
fn($k, $v) => "$k=$v",
array_keys($tags),
array_values($tags)
));
}
return "metrics:$metric$tagString";
}
public function flush(): void {
// Send metrics to monitoring system
$keys = $this->redis->keys('metrics:*');
foreach ($keys as $key) {
$value = $this->redis->get($key);
// Send to StatsD, Prometheus, etc.
$this->sendMetric($key, $value);
}
}
private function sendMetric(string $key, $value): void {
// Implementation depends on monitoring system
// Example: StatsD
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
$metric = str_replace('metrics:', '', $key);
$packet = "$metric:$value|c";
socket_sendto($socket, $packet, strlen($packet), 0, '127.0.0.1', 8125);
socket_close($socket);
}
}

Testing Strategies

API Testing

<?php
class APITestCase extends TestCase {
protected ApiClient $client;
protected DatabaseSeeder $seeder;
protected function setUp(): void {
parent::setUp();
$this->client = new ApiClient('http://localhost:8000');
$this->seeder = new DatabaseSeeder();
}
public function testCreateUser(): void {
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123'
];
$response = $this->client->post('/api/users', $userData);
$this->assertEquals(201, $response->getStatusCode());
$this->assertJsonStructure($response->getBody(), [
'id', 'name', 'email', 'created_at'
]);
// Verify user was created in database
$this->assertDatabaseHas('users', [
'email' => 'john@example.com'
]);
}
public function testRateLimiting(): void {
$this->seeder->createUser(['email' => 'test@example.com']);
// Make requests up to limit
for ($i = 0; $i < 100; $i++) {
$response = $this->client->get('/api/users/1');
$this->assertEquals(200, $response->getStatusCode());
}
// Next request should be rate limited
$response = $this->client->get('/api/users/1');
$this->assertEquals(429, $response->getStatusCode());
}
public function testConcurrentRequests(): void {
$responses = [];
$promises = [];
// Create 10 concurrent requests
for ($i = 0; $i < 10; $i++) {
$promises[] = $this->client->getAsync('/api/users');
}
$responses = Promise::all($promises)->wait();
// All requests should succeed
foreach ($responses as $response) {
$this->assertEquals(200, $response->getStatusCode());
}
}
}

Best Practices Summary

  • Layered architecture: Separate concerns into distinct layers
  • Domain modeling: Use domain-driven design principles
  • CQRS: Separate read and write operations
  • Event-driven: Use events for loose coupling
  • Caching: Cache at multiple levels
  • Rate limiting: Protect against abuse
  • Circuit breakers: Handle external service failures
  • Security: Implement proper authentication and authorization
  • Documentation: Maintain up-to-date API documentation
  • Monitoring: Collect metrics and logs
  • Testing: Comprehensive testing strategy

Building scalable APIs requires careful planning and implementation of proven patterns. Start with a solid architectural foundation, implement proper caching and rate limiting, and continuously monitor and optimize performance. Remember that scalability is not just about handling more requests—it's about building systems that can evolve and grow with your business needs.