API Platform at Enterprise Scale: GraphQL, Pagination, and N+1 Defenses in Symfony
The first time a team runs API Platform GraphQL Symfony in production, the demo is glorious. A single annotation exposes an entity over REST and GraphQL at once, the OpenAPI docs generate themselves, and the frontend team stops filing tickets for endpoints. Six months later the same setup is paging the on-call engineer at 2am because one nested GraphQL query is firing 4,000 database round trips and a paginated collection endpoint just tried to serialize half a million rows into a single response.
None of this is a flaw in the framework. API Platform is doing exactly what you told it to. The problem is that the defaults that make a prototype effortless are not the defaults that survive enterprise traffic. This post walks through the three failure modes that show up at scale, GraphQL resolution cost, unbounded pagination, and the classic N+1 query, and the concrete patterns that keep each one from reaching production.
Why scale changes the rules
At prototype size every query is fast because every table is small. A naive SELECT that joins three entities and ignores indexes returns in two milliseconds against a thousand rows. The same query against four million rows, run a few hundred times per second, is a different organism. Two things break the prototype assumptions: data volume grows, and access patterns become adversarial. Real clients request deeply nested data, ask for page 9,000, and hammer the cheapest-looking endpoint in a loop.
API Platform sits on top of Doctrine, and Doctrine is a lazy-loading ORM. Lazy loading is wonderful for developer ergonomics and treacherous for performance, because the cost of a relationship is invisible at the point you write the code and only becomes visible under load. Scaling an API Platform application is largely the discipline of making those hidden costs explicit and bounded.
N+1 queries: the default failure mode
The N+1 problem is simple to state. You fetch a collection of N entities with one query, then access a relationship on each one, triggering N additional queries. Fetch 100 orders, read order.customer.name on each, and you have just run 101 queries instead of 2. Under GraphQL this compounds, because a client can nest relationships several levels deep in a single request and each level multiplies the round trips.
The first defense is the EntityManager's ability to fetch related data eagerly. For REST collections, attach a Doctrine extension that adds a join and a WITH fetch to the query builder for the relationships you know will be read. API Platform's QueryCollectionExtensionInterface is the right hook: you receive the query builder before it executes and can add leftJoin and addSelect calls so the related entities arrive in the same result set.
public function applyToCollection(
QueryBuilder $qb,
QueryNameGeneratorInterface $gen,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
if (Order::class !== $resourceClass) {
return;
}
$alias = $qb->getRootAliases()[0];
$qb->leftJoin("$alias.customer", 'c')->addSelect('c');
}
For GraphQL the join trick alone is not enough, because the client decides the shape of the query at request time and you cannot pre-join every possible path. This is where the DataLoader pattern earns its place. A DataLoader batches the relationship lookups that happen during a single GraphQL resolution: instead of resolving customer for each of 100 orders individually, it collects the 100 customer IDs, issues one WHERE id IN (...) query, and hands each resolver its result. API Platform integrates with this through custom resolvers and the webonyx/graphql-php runtime underneath. The rule of thumb is blunt: any relationship reachable in a GraphQL schema needs either an eager join or a batched loader, never a per-row lazy fetch.
The cheapest detection tool is already in your stack. Enable the Doctrine SQL logger in your test environment and assert on query counts. A functional test that fires a representative GraphQL query and fails if the database was touched more than a fixed number of times turns N+1 regressions into red CI runs instead of 2am pages.
Pagination: never let the client choose unbounded
API Platform paginates collections by default, which feels like the problem is already solved. It is not, for two reasons. First, the default page size is a starting point, not a ceiling, and clients can often override it through the itemsPerPage parameter. Without a hard maximum, a single request can ask for fifty thousand items and force the database and serializer to materialize all of them. Second, offset pagination itself degrades. LIMIT 20 OFFSET 100000 forces the database to count through a hundred thousand rows before discarding them, so deep pages get slower the further a client scrolls.
The first fix is a configuration discipline. Set both a default and a maximum items-per-page, and make the maximum enforceable rather than advisory.
#[ApiResource(
paginationItemsPerPage: 30,
paginationMaximumItemsPerPage: 100,
paginationClientItemsPerPage: true
)]
class Order {}
With paginationMaximumItemsPerPage set, a client asking for 50,000 gets 100. That single line removes an entire class of accidental and deliberate denial-of-service.
The deeper fix for large datasets is cursor-based pagination. Instead of an offset, the client passes an opaque cursor that encodes the last seen sort key, and the query becomes WHERE id > :cursor ORDER BY id LIMIT 30. That query uses the index and stays constant-time no matter how deep the client scrolls. API Platform supports cursor pagination natively through the paginationViaCursor attribute, paired with a properly indexed and ordered field. The tradeoff is that clients lose the ability to jump to an arbitrary page number, which is almost always acceptable for infinite-scroll and API-to-API integrations and almost never acceptable for a classic page-number UI. Choose per resource based on how the data is actually consumed.
GraphQL-specific cost controls
GraphQL hands query construction to the client, which is the feature and the risk. Two controls keep that power bounded. Query depth limiting rejects requests that nest beyond a configured number of levels, stopping a malicious or careless query from walking the entire object graph. Query complexity analysis assigns a cost to each field and rejects requests whose total exceeds a budget, which catches the wide-and-shallow queries that depth limiting misses. Both are configured in the GraphQL layer and both should be set before the first external client touches the schema.
It is also worth being deliberate about which entities are exposed to GraphQL at all. The annotation that makes exposure trivial also makes over-exposure trivial. Restrict mutations and sensitive read paths with security expressions, and keep the schema surface to what clients genuinely need. A smaller schema is a smaller attack surface and a smaller performance surface at the same time.
Serialization groups keep payloads honest
The last enterprise-scale lever is what leaves the building. API Platform's normalization groups control which fields appear in a response. Without explicit groups, an entity tends to serialize everything, including relationships you did not intend to expose and computed fields that trigger extra queries during normalization. Define read and write groups per operation so each endpoint returns a deliberate, minimal payload.
#[ApiResource(
normalizationContext: ['groups' => ['order:read']],
denormalizationContext: ['groups' => ['order:write']]
)]
class Order
{
#[Groups(['order:read'])]
public int $id;
#[Groups(['order:read', 'order:write'])]
public string $reference;
}
Tight groups do double duty. They shrink response size, which matters on mobile networks, and they prevent the serializer from touching relationships that would otherwise trigger lazy loads. Many an N+1 turns out to originate not in the query but in a serialization group that was wider than anyone realized.
Putting it together
Running API Platform GraphQL Symfony at enterprise scale is not about fighting the framework. It is about replacing the prototype defaults with explicit, bounded choices: eager joins and DataLoaders so relationships never lazy-load per row, hard pagination maximums and cursor pagination so collections stay constant-time, depth and complexity limits so GraphQL cannot be weaponized, and tight serialization groups so payloads carry only what was asked for. Add a query-count assertion to CI and most of these regressions get caught before they ship.
The pattern underneath all of it is the same one good engineering teams apply everywhere: make the expensive thing visible, then bound it. If your team is staring at an API that demoed beautifully and now buckles under real traffic, that is a solvable problem, and usually a faster one to fix than to live with.
At Wolf-Tech we help product teams harden Symfony and API Platform systems for production load, from custom software development through targeted code quality consulting on the API layer you already have. If a performance review of your API would help, write to hello@wolf-tech.io or find us at wolf-tech.io.

