Optimizing PHP/Symfony API speed: seven levers ranked by impact
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.
- N+1 detection on 3 critical endpoints (
invoices,customers,analytics). Fix with fetch-join and DTOs. p95 drops to 680 ms. - Serializer replaced with projection DTOs on the 3 endpoints. p95 at 420 ms.
- ETag enabled on all GET endpoints. 304 rate: 35%. p95 at 310 ms.
- Redis cache on
dashboardendpoints (5 min TTL). p95 at 180 ms. - Cursor pagination on
invoices(replacesLIMIT/OFFSET). p95 at 140 ms. - Brotli enabled. Payload weight -20%.
- 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.