Multiple grain elevator structures of different eras and construction methods built alongside each other in the Buffalo, NY grain elevator district, with rail sidings converging in the foreground
PHP

Building Scalable Backend APIs with Modern PHP

•16 min read•By Joseph Edmonds•Image: HAER/National Park Service, public domain, via Wikimedia Commons

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

Building scalable APIs means creating systems that can grow alongside your business whilst holding onto performance, reliability, and maintainability, even under heavy traffic. Modern PHP gives you solid tools for building APIs that can cope with that kind of growth.

This article covers architectural patterns, design principles, and implementation strategies for building 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 App\Http\Controllers;

use App\Domain\User\User;
use App\Exceptions\{DuplicateEmailException, ValidationException};
use App\Http\{JsonResponse, Request, Response};
use App\ValueObjects\{EmailAddress, UserId, UserName};
use PDO;
use Psr\Log\LoggerInterface;

// 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 App\Domain\User;

use App\Domain\{AggregateRoot};
use App\Exceptions\{InvalidStateTransitionException, UserAlreadyDeactivatedException};
use App\ValueObjects\{EmailAddress, PasswordHash, UserId, UserName};
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

declare(strict_types=1);

// 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 optimised read model
        return $this->userReadModel->getUserById($query->userId);
    }
}

// Read Model - Optimised 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 new UserView(json_decode($cached, true));
        }

        // 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();

        // Cache the raw row, not the hydrated object - that avoids running deserialisation
        // helpers over cache data, which is a known object-injection vector.
        $this->redis->setex("user:$userId", 3600, json_encode($userData));

        return new UserView($userData);
    }
}

UserReadModel caches the raw database row rather than the hydrated object, then rebuilds UserView on both the cache-hit and cache-miss paths. That's a deliberate choice: it means the cache round-trip goes through json_encode()/json_decode() rather than PHP's native serialize()/unserialize(), which is a well-known object-injection vector when applied to data an attacker could tamper with.

Event-Driven Architecture

Decouple components using events:

<?php

declare(strict_types=1);

// 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 Optimisation

Database Connection Pooling

<?php

declare(strict_types=1);

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

This pattern only pools connections within a single persistent process (Swoole, RoadRunner, or a long-running worker). A standard PHP-FPM deployment spins up or reuses a fresh process per request, so an instance property like $connections does not survive between requests and provides no real pooling benefit. On PHP-FPM, reach for PDO::ATTR_PERSISTENT, an external pooler such as PgBouncer or ProxySQL, or a supervisor-managed long-running process instead.

Response Caching

<?php

declare(strict_types=1);

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 ??= $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(json_encode($components));
    }
}

Rate Limiting and Throttling

Token Bucket Algorithm

<?php

declare(strict_types=1);

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

declare(strict_types=1);

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

declare(strict_types=1);

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

declare(strict_types=1);

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 ??= $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

declare(strict_types=1);

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

declare(strict_types=1);

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

The example below is a Laravel TestCase (it relies on Laravel's assertJsonStructure and assertDatabaseHas helpers). Swap those for the equivalent assertions if you're on a framework-agnostic PHPUnit setup.

<?php

declare(strict_types=1);

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'    => '[email protected]',
            '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' => '[email protected]',
        ]);
    }

    public function testRateLimiting(): void
    {
        $this->seeder->createUser(['email' => '[email protected]']);

        // 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());
        }
    }
}

Scalable API Checklist

  • Layered architecture: Separate concerns into distinct layers
  • Domain modelling: 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 authorisation
  • 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 optimise performance, remembering that scalability is about building systems that can evolve and grow with your business needs, not just about handling more requests.

Got a PHP system that needs this kind of attention?

Get in touch