YOUR DIGITAL HUB
← Back to blog

Optimizing PHP/Symfony API speed: seven levers ranked by impact

· 9 min read
Cover — Optimizing a PHP Symfony API's speed

A slow API kills adoption

Amazon published in 2006 a now-classic benchmark: 100 ms of extra latency cost 1% of conversion. Google confirmed comparable figures on search. On a B2B API billed by usage, the effect is just as brutal: beyond 500 ms at p95, clients start adding their own cache to bypass you, your perceived value collapses.

Latency budget we contractually commit to on APIs we deliver:

  • p50 under 80 ms.
  • p95 under 200 ms.
  • p99 under 500 ms.
  • 5xx error rate under 0.1%.

These numbers are not invented: they correspond to what a Symfony 7 + PostgreSQL + Redis architecture holds without acrobatics, provided seven levers are applied in the right order of impact-to-effort.

The seven levers

Order we follow on tuning engagements.

# Lever Effort Typical latency impact
1 Detect and fix N+1 queries Low -50 to -80%
2 Choose the right serializer Low -15 to -40%
3 HTTP cache (ETag, Cache-Control, Varnish) Medium -70 to -95% on hit
4 Redis application cache Medium -60 to -90% on hit
5 Efficient pagination (cursor) Medium -50% on deep offsets
6 Brotli compression Low -20 to -30% payload size
7 HTTP/2 and HTTP/3 Low to medium -10 to -30% depending on network latency

Compound gain on a typical API: from 800 ms to 80 ms at p95. Real 2025 engagement, measured by Blackfire and Datadog before/after.

1. Detecting N+1

The N+1 is the number-one problem for Doctrine and Eloquent APIs. The code looks like this.

// Bad: 1 + N queries
$invoices = $entityManager->getRepository(Invoice::class)->findAll();
foreach ($invoices as $invoice) {
    $customer = $invoice->getCustomer(); // extra query per invoice
}

On 500 invoices, this code fires 501 SQL queries. Each one costs a network round-trip. Even locally, 5 to 50 ms per query. The fix is to load relations together.

// Good: 1 query
$invoices = $entityManager->createQueryBuilder()
    ->select('i', 'c')
    ->from(Invoice::class, 'i')
    ->leftJoin('i.customer', 'c')
    ->getQuery()
    ->getResult();

The Symfony profiler (dev toolbar) counts SQL queries per page. Rule we enforce in CI: no more than 5 SQL queries for a list endpoint, no more than 10 for a complex aggregated endpoint. Beyond that, doctrine/doctrine-bundle flags a problem.

To catch N+1s automatically in CI, we use a Blackfire assertion.

blackfire run --samples=3 \
  --assert='metrics.sql.queries.count <= 5' \
  php bin/phpunit tests/Api/InvoiceListTest.php

2. Picking the right serializer

JSON serialization easily consumes 30% of a PHP API's CPU time. On a list endpoint with 100 nested objects, the default Symfony Serializer can spend 150 ms building the response. Alternatives exist.

Benchmark we ran on an Invoice entity with 5 relations and 100 objects per response (PHP 8.3, Symfony 7.2, mean over 1000 requests).

Serializer Mean time Relative
Symfony Serializer (normalizers, groups) 148 ms 100%
JMS Serializer 112 ms 76%
API Platform Serializer (optimized groups) 68 ms 46%
Symfony Serializer + direct json_encode on DTO 12 ms 8%

The last row is decisive. Going through a pure projection DTO (an object with public typed properties, no reflection, no groups, no metadata) and calling json_encode directly is 12x faster than classic Symfony groups. We generalize this pattern on high-load endpoints.

// Projection DTO
final readonly class InvoiceListItemDto
{
    public function __construct(
        public int $id,
        public string $number,
        public string $customerName,
        public int $amountCents,
        public string $currency,
        public string $issuedAt,
    ) {}
}

// Controller
#[Route('/api/invoices', methods: ['GET'])]
public function list(InvoiceRepository $repo): JsonResponse
{
    // Native projection via QueryBuilder with SELECT NEW
    $dtos = $repo->listProjections(page: 1, limit: 20);
    return new JsonResponse($dtos);
}

Doctrine's SELECT NEW builds the DTOs directly from SQL, without entity hydration, without UnitOfWork.

3. HTTP cache: the best latency is the one avoided

A request that never reaches the server is infinitely faster than an optimized one. HTTP cache is the most profitable lever for read-heavy endpoints.

ETag and If-None-Match

Symfony handles ETags natively.

use Symfony\Component\HttpFoundation\Response;

#[Route('/api/invoices/{id}', methods: ['GET'])]
public function show(Invoice $invoice, Request $request): Response
{
    $response = new JsonResponse($this->mapper->toDto($invoice));
    $response->setEtag(md5($invoice->getUpdatedAt()->format('U') . $invoice->getId()));
    $response->setPublic();
    $response->setMaxAge(60);

    if ($response->isNotModified($request)) {
        return $response; // 304 Not Modified, empty body
    }

    return $response;
}

The client sends If-None-Match: "hash" on the next request. If the hash matches, the server replies 304 in under 10 ms (no serialization, no DB query if done right).

Cache-Control and Varnish

In front of the application, a reverse proxy (Varnish, or Nginx with proxy_cache) absorbs unauthenticated traffic. Minimal Varnish config to cache public endpoints.

vcl 4.1;

backend default {
    .host = "app";
    .port = "80";
}

sub vcl_recv {
    # Do not cache authenticated requests
    if (req.http.Authorization || req.http.Cookie ~ "PHPSESSID") {
        return (pass);
    }
    if (req.method != "GET") {
        return (pass);
    }
    return (hash);
}

sub vcl_backend_response {
    if (bereq.url ~ "^/api/public/") {
        set beresp.ttl = 60s;
        set beresp.grace = 1h;
        set beresp.http.Cache-Control = "public, max-age=60";
    }
}

On a GET /api/public/services endpoint with 10,000 requests per minute, Varnish absorbs 99% of traffic with latency under 5 ms.

4. Redis application cache

Application cache kicks in after authentication, on data shared across different users' requests or on expensive projections.

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

#[Route('/api/dashboard/stats', methods: ['GET'])]
public function stats(CacheInterface $cache, StatsService $service): JsonResponse
{
    $tenantId = $this->getUser()->getTenantId();
    $data = $cache->get(
        "dashboard.stats.{$tenantId}",
        function (ItemInterface $item) use ($service, $tenantId) {
            $item->expiresAfter(300);
            $item->tag(["tenant.{$tenantId}", 'dashboard']);
            return $service->compute($tenantId);
        }
    );
    return new JsonResponse($data);
}

Tags enable targeted invalidation. When an invoice changes, we invalidate tenant.42 and all dependent projections are recomputed. Wire symfony/cache with a RedisTagAwareAdapter and use Redis Stack for native tag support.

Symfony 6.3+ #[Cache] attribute for simple cases:

use Symfony\Component\HttpKernel\Attribute\Cache;

#[Route('/api/invoices/{id}', methods: ['GET'])]
#[Cache(smaxage: 60, maxage: 30, public: true)]
public function show(Invoice $invoice): JsonResponse
{
    return new JsonResponse($this->mapper->toDto($invoice));
}

5. Efficient pagination: cursor over offset

Offset pagination (LIMIT 20 OFFSET 10000) is a classic trap. PostgreSQL must scan the first 10,020 rows before returning the 20 requested. On a 10-million-row table, OFFSET 500000 takes 3 seconds.

Cursor pagination sidesteps the issue by using a WHERE on the ordering column.

-- Page 1: no cursor
SELECT id, number, issued_at
FROM invoice
WHERE tenant_id = 42
ORDER BY issued_at DESC, id DESC
LIMIT 20;

-- Next page: cursor = (issued_at, id) of the last received item
SELECT id, number, issued_at
FROM invoice
WHERE tenant_id = 42
  AND (issued_at, id) < ('2026-06-01 10:00:00', 12345)
ORDER BY issued_at DESC, id DESC
LIMIT 20;

The (tenant_id, issued_at DESC, id DESC) index makes every page equal in cost, regardless of depth. Constant time, versus linear cost with offset.

The Relay spec (GraphQL) popularized the format: an edges list with base64-encoded cursor per row, plus pageInfo.endCursor and pageInfo.hasNextPage. On a REST API, adopt RFC 8288 with Link: <...>; rel="next" headers.

6. Compression: Brotli over gzip

Gzip is universal but Brotli compresses 20 to 25% more efficiently on JSON. Nginx config.

# nginx.conf
http {
    brotli on;
    brotli_comp_level 4;
    brotli_types application/json application/javascript text/css text/html;

    gzip on;
    gzip_vary on;
    gzip_comp_level 5;
    gzip_types application/json application/javascript text/css text/html;
}

Important: brotli_comp_level 4 is enough. Cranking to 11 spikes CPU for a marginal gain. Compress on the fly, not ahead of time.

On a 120 KB JSON payload, gzip yields 18 KB, Brotli 14 KB. On a mobile API over slow 4G, that is 50 to 100 ms saved on download.

7. HTTP/2 and HTTP/3

HTTP/1.1 opens one TCP connection per parallel request. Browsers cap at 6 per origin. On an SPA page triggering 40 API calls, requests queue up.

HTTP/2 multiplexes everything over a single connection. Ten API requests run in parallel without TCP overhead. Typical latency gain on initial SPA load: 20 to 40%.

HTTP/3 goes further with QUIC (UDP-based). Handshake is halved, recovery after packet loss is nearly instantaneous. Critical on mobile where losses are common. Cloudflare, Fastly and modern reverse proxies support HTTP/3 with a single toggle.

Enabling on Nginx 1.25+:

server {
    listen 443 ssl http2;
    listen 443 quic reuseport;

    http2 on;
    http3 on;
    http3_hq on;
    quic_retry on;

    add_header Alt-Svc 'h3=":443"; ma=86400';

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # ...
}

HTTP/2 "early hints" (103 Early Hints) also let the server tell the client which resources to preload before the main response is ready. Symfony 6.4+ supports them via ServerSendEvent.

Target architecture

Recommended stack for a Symfony API holding 1000 requests per second at p95 under 200 ms.

 ┌──────────┐    HTTP/3     ┌──────────┐    Cache    ┌──────────┐
 │ Client   │─────────────▶│ Cloudflare│────────────▶│ Varnish  │
 │ (mobile, │               │ or Fastly │             │ reverse  │
 │  SPA)    │               └──────────┘             │  proxy   │
 └──────────┘                                        └────┬─────┘
                                                          │ miss
                                                          ▼
                                                     ┌──────────┐
                                                     │  Nginx   │
                                                     │ + FPM/   │
                                                     │ FrankenPHP│
                                                     └────┬─────┘
                                                          │
                                          ┌───────────────┼────────────┐
                                          ▼               ▼            ▼
                                     ┌─────────┐   ┌──────────┐  ┌──────────┐
                                     │  Redis  │   │PostgreSQL│  │Elasticsearch│
                                     │ (cache) │   │  (data)  │  │ (search) │
                                     └─────────┘   └──────────┘  └──────────┘

Redis absorbs repetitive queries. PostgreSQL only sees cache-miss reads and writes. Elasticsearch serves full-text search. Varnish absorbs anonymous or lightly personalized traffic. Cloudflare/Fastly serve static assets and edge cache.

Continuous monitoring

Once in production, ignoring metrics means letting them drift. Minimum dashboards to wire on Prometheus or Datadog:

  • p50, p95, p99 latency per endpoint, alert when p95 goes above 300 ms for more than 5 minutes.
  • 5xx error rate per endpoint, alert if above 0.5%.
  • HTTP cache hit ratio (Varnish) and application cache hit ratio (Redis).
  • SQL queries per transaction (via APM), alert on sudden increase.
  • CPU time and memory per FPM worker.

Client case: from 1.2 s to 110 ms at p95

Real 2025 engagement: a Symfony 6 API for a B2B platform with 40,000 active users. Initial state: p95 at 1.2 s, peaks at 4 s under load, daily customer complaints. Applying the levers in order.

  1. N+1 detection on 3 critical endpoints (invoices, customers, analytics). Fix with fetch-join and DTOs. p95 drops to 680 ms.
  2. Serializer replaced with projection DTOs on the 3 endpoints. p95 at 420 ms.
  3. ETag enabled on all GET endpoints. 304 rate: 35%. p95 at 310 ms.
  4. Redis cache on dashboard endpoints (5 min TTL). p95 at 180 ms.
  5. Cursor pagination on invoices (replaces LIMIT/OFFSET). p95 at 140 ms.
  6. Brotli enabled. Payload weight -20%.
  7. HTTP/2 enabled on the reverse proxy. Gain on SPA load.

Final result: p95 at 110 ms, p99 at 320 ms. Total mission time: 18 developer days spread over 5 weeks. Immediate ROI (customer retention +4%, infra cost -30%).

Conclusion

A slow API is rarely slow for a single reason. The sum of seven well-chosen micro-optimizations makes the difference between a product endured and one loved. The key is discipline: measure before every change, apply one lever at a time, systematic benchmark.

For a latency audit on your PHP API, a tuning engagement or scoping before a rebuild, reach out at contact@your-digital-hub.com or explore our performance and scalability service. See also our article RAG in production with pgvector, Claude and Symfony for an end-to-end architecture.