The Symfony GraphQL Production Cheatsheet
7 patterns that stop your database from catching fire.
Who this is for
You're shipping (or maintaining) a Symfony app that exposes GraphQL - probably with overblog/GraphQLBundle, api-platform, or webonyx/graphql-php directly. Your queries work in dev. They die in production. You've already googled "GraphQL N+1 Symfony" and bounced off three half-answers and one outdated Medium post.
This is the cheatsheet I wish someone had handed me five years ago.
Each pattern below is short on theory and heavy on code you can paste into your project today.
Contents
- Kill N+1 with a DataLoader
- Cap query complexity and depth
- Field-level authorization with Voters
- Two-tier error handling
- Errors-as-data for mutations
- Cursor pagination, not offset
- File uploads via the multipart spec
- Production launch checklist
Pattern 1 - Kill N+1 with a DataLoader (the 100->2 query trick)
The trap. Innocent-looking resolver:
// PropertyType resolver
public function owner(Property $property): User
{
return $this->userRepository->find($property->getOwnerId());
}
Query 100 properties -> 1 query for properties + 100 queries for owners = 101 queries. Your DBA opens a Jira ticket with your name on it.
The fix. Batch IDs across the whole GraphQL execution and resolve them in one query, using the DataLoader pattern.
composer require overblog/dataloader-bundle
namespace App\GraphQL\DataLoader;
use Overblog\DataLoader\DataLoader;
use Overblog\PromiseAdapter\PromiseAdapterInterface;
use App\Repository\UserRepository;
class UserDataLoader
{
private ?DataLoader $loader = null;
// The bundle registers the promise adapter as a service, so it autowires.
public function __construct(
private UserRepository $userRepository,
private PromiseAdapterInterface $promiseAdapter,
) {}
public function getLoader(): DataLoader
{
// DataLoader requires the promise adapter as its 2nd constructor argument.
return $this->loader ??= new DataLoader(function (array $ids) {
$users = $this->userRepository->findBy(['id' => $ids]);
$byId = [];
foreach ($users as $u) {
$byId[$u->getId()] = $u;
}
// CRITICAL: return a *Promise* resolving to one value per requested ID,
// in the same order as $ids (a plain array won't work).
return $this->promiseAdapter->createFulfilled(
array_map(fn($id) => $byId[$id] ?? null, $ids)
);
}, $this->promiseAdapter);
}
}
Resolver becomes:
public function owner(Property $property): \GraphQL\Executor\Promise\Promise
{
return $this->userDataLoader->getLoader()->load($property->getOwnerId());
}
Now: 1 query for properties + 1 query for all owners = 2 queries. ~50× reduction.
Pro tip - catch regressions automatically. Add a Doctrine query counter middleware in dev that logs a warning whenever a single GraphQL request issues more than N queries. New N+1s show up in your CI logs the day they're introduced, not the day they hit production.
Pattern 2 - Cap query complexity and depth (or get DDoSed for free)
The trap. A bored attacker (or a confused frontend dev) sends:
query Bomb {
property(id: 1) {
owner {
properties {
owner {
properties {
owner { properties { ... } }
}
}
}
}
}
}
Your server cheerfully tries to expand this into thousands of joined entities. CPU goes to 100%. Pager goes off.
The fix. Two limits, both enforced before resolution starts.
# config/packages/graphql.yaml
overblog_graphql:
security:
query_max_complexity: 200
query_max_depth: 7
enable_introspection: '%kernel.debug%' # always false in prod
For expensive list fields, declare a complexity cost so paginated requests can't sneak past:
# in your schema YAML
properties:
type: "[Property!]!"
args:
first: { type: "Int", defaultValue: 20 }
complexity: "@=100 + (args['first'] ?? 20) * childrenComplexity"
A nested query asking for first: 100 properties × children now blows past 200 instantly and gets rejected with a clear error before a single SQL query runs.
Pattern 3 - Authorization at the field level (Voters, not controllers)
The trap. GraphQL has one HTTP endpoint. Putting auth in your controller protects nothing - every field needs its own check, and "is the user logged in" isn't enough.
The fix. Symfony Voters + GraphQL Bundle's access expressions.
In your schema:
Property:
type: object
config:
fields:
id: { type: "ID!" }
title: { type: "String!" }
internalNotes:
type: "String"
access: "@=isGranted('EDIT', object)" # object = the parent Property
The Voter:
namespace App\Security\Voter;
use App\Entity\Property;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class PropertyVoter extends Voter
{
public function __construct(private Security $security) {}
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof Property
&& in_array($attribute, ['VIEW', 'EDIT', 'DELETE'], true);
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return $attribute === 'VIEW' && $subject->isPublished();
}
return match ($attribute) {
'VIEW' => $subject->isPublished() || $subject->getOwner() === $user,
'EDIT', 'DELETE' => $subject->getOwner() === $user
|| $this->security->isGranted('ROLE_ADMIN'),
default => false,
};
}
}
UX rule. When a field is unauthorized, return null (or omit it) - don't throw. Throwing collapses the whole query branch and confuses your frontend. Reserve thrown errors for mutations and for serious auth violations you want logged.
Pattern 4 - Two-tier error handling (don't leak stack traces)
The trap. A \PDOException bubbles up. Your client sees:
"Cannot fetch property: SQLSTATE[42S22]: Column 'foo' not found in FooRepository.php line 247"
Congratulations, you just published your schema and an attack hint to the world.
The fix. Two error tiers - user errors (safe to display) and internal errors (logged, generic message returned).
namespace App\GraphQL\Exception;
interface UserError {} // marker interface
class PropertyNotFoundException extends \RuntimeException implements UserError {}
class InvalidPriceException extends \RuntimeException implements UserError {}
class RateLimitedException extends \RuntimeException implements UserError {}
Configure the bundle to expose user errors transparently:
# config/packages/graphql.yaml
overblog_graphql:
errors_handler:
# instanceof matching - so the marker interface catches every implementor.
# Without this, overblog matches exact class names only and the interface is ignored.
map_exceptions_to_parent: true
exceptions:
errors:
- App\GraphQL\Exception\UserError
And a custom error formatter for everything else:
namespace App\GraphQL;
use Psr\Log\LoggerInterface;
use App\GraphQL\Exception\UserError;
class ErrorFormatter
{
public function __construct(private LoggerInterface $logger) {}
public function __invoke(\Throwable $error): array
{
$previous = $error->getPrevious() ?? $error;
if ($previous instanceof UserError) {
return [
'message' => $previous->getMessage(),
'extensions' => ['code' => $this->codeFor($previous)],
];
}
$this->logger->error('Unhandled GraphQL error', [
'exception' => $previous,
]);
return [
'message' => 'An internal error occurred',
'extensions' => ['code' => 'INTERNAL_ERROR'],
];
}
private function codeFor(\Throwable $e): string
{
$short = (new \ReflectionClass($e))->getShortName();
// PropertyNotFoundException -> PROPERTY_NOT_FOUND
return strtoupper(preg_replace('/(?<!^)([A-Z])/', '_$1',
str_replace('Exception', '', $short)));
}
}
Pattern 5 - Errors-as-data for mutations (your frontend will thank you)
The trap. You throw a GraphQL error for "email already taken" or "password too weak." Frontend now has to:
- Parse
errors[].messagestrings, - Guess which field they map to,
- Pray nobody changes the wording.
The fix. Make recoverable errors part of the response shape, not the GraphQL errors array.
input RegisterUserInput {
email: String!
password: String!
}
type RegisterUserPayload {
user: User
errors: [UserInputError!]!
}
type UserInputError {
field: String! # "email", "password.length", etc.
code: String! # "EMAIL_TAKEN", "PASSWORD_TOO_SHORT" - stable identifier
message: String! # human-readable, can be localized
}
extend type Mutation {
registerUser(input: RegisterUserInput!): RegisterUserPayload!
}
Resolver:
public function registerUser(array $input): array
{
$errors = $this->validator->validate($input);
if (count($errors) > 0) {
return [
'user' => null,
'errors' => $this->formatValidationErrors($errors),
];
}
try {
$user = $this->userService->register($input);
} catch (EmailAlreadyTakenException) {
return [
'user' => null,
'errors' => [[
'field' => 'email',
'code' => 'EMAIL_TAKEN',
'message' => 'This email is already registered.',
]],
];
}
return ['user' => $user, 'errors' => []];
}
Frontend writes payload.errors.find(e => e.field === 'email') and never touches try/catch. Type-safe end to end. Translatable. Stable across schema changes.
Use the GraphQL errors array only for things the user can't fix: auth failures, server crashes, rate limits.
Pattern 6 - Cursor pagination, not offset (or: how to survive 100k+ rows)
The trap. LIMIT 50 OFFSET 50000. The database scans 50,050 rows to throw away 50,000. At a million rows you can hear the disks crying.
The fix. Relay-style cursor pagination.
type Query {
properties(first: Int = 20, after: String): PropertyConnection!
}
type PropertyConnection {
edges: [PropertyEdge!]!
pageInfo: PageInfo!
totalCount: Int # OPTIONAL - expensive on big tables, cache aggressively
}
type PropertyEdge {
node: Property!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Cursor = base64 of (sort key + tiebreaker ID). Sort key alone isn't enough because two rows can share it.
public function properties(int $first = 20, ?string $after = null): array
{
$first = min($first, 100); // hard cap
$qb = $this->em->createQueryBuilder()
->select('p')->from(Property::class, 'p')
->orderBy('p.createdAt', 'DESC')
->addOrderBy('p.id', 'DESC') // tiebreaker -> stable order
->setMaxResults($first + 1); // +1 to detect hasNextPage
if ($after !== null) {
[$createdAt, $id] = $this->decodeCursor($after);
$qb->andWhere('(p.createdAt < :ca) OR (p.createdAt = :ca AND p.id < :id)')
->setParameter('ca', new \DateTimeImmutable($createdAt))
->setParameter('id', $id);
}
$items = $qb->getQuery()->getResult();
$hasNextPage = count($items) > $first;
if ($hasNextPage) array_pop($items);
return [
'edges' => array_map(fn(Property $p) => [
'node' => $p,
'cursor' => $this->encodeCursor($p),
], $items),
'pageInfo' => [
'hasNextPage' => $hasNextPage,
'endCursor' => $items ? $this->encodeCursor(end($items)) : null,
],
];
}
private function encodeCursor(Property $p): string
{
return base64_encode($p->getCreatedAt()->format(\DateTimeInterface::ATOM) . '|' . $p->getId());
}
private function decodeCursor(string $cursor): array
{
return explode('|', base64_decode($cursor), 2);
}
Why it matters. Cursor pagination is O(log n) on an indexed column; offset is O(n). At 100k rows the difference is invisible; at 1M it's the difference between 30ms and 3000ms per page.
About totalCount. Don't expose it casually - SELECT COUNT(*) over filtered millions is brutal. Either cache it (Redis, 60s TTL), use EXPLAIN-based estimates for big lists, or simply omit it.
Pattern 7 - File uploads via the GraphQL multipart spec
The trap. Sending base64 files in mutations. Your 5 MB photo becomes a ~7 MB JSON blob, gets parsed in memory, and your PHP-FPM worker chews through RAM for nothing.
The fix. Use the GraphQL multipart request spec. overblog/GraphQLBundle supports the Upload scalar out of the box.
scalar Upload
input UploadPropertyPhotoInput {
propertyId: ID!
photo: Upload!
altText: String
}
extend type Mutation {
uploadPropertyPhoto(input: UploadPropertyPhotoInput!): Photo!
}
Resolver - note UploadedFile arrives natively:
use Symfony\Component\HttpFoundation\File\UploadedFile;
public function uploadPropertyPhoto(array $input): Photo
{
/** @var UploadedFile $file */
$file = $input['photo'];
// Validate by content, not extension
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file->getPathname());
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
throw new InvalidUploadException('Unsupported image type.');
}
if ($file->getSize() > 10 * 1024 * 1024) {
throw new InvalidUploadException('File too large (max 10 MB).');
}
// Persist the file metadata synchronously…
$photo = $this->photoService->createPending(
propertyId: $input['propertyId'],
file: $file,
altText: $input['altText'] ?? null,
);
// …and push expensive work (resize, watermark, S3 upload, EXIF strip) to a queue.
$this->bus->dispatch(new ProcessPhotoMessage($photo->getId()));
return $photo;
}
Three things that will save your weekend:
- Validate MIME from file content, not from
$_FILES['type'](the client sets that - never trust it). - Push processing (resize, EXIF strip, S3 upload) to RabbitMQ via Symfony Messenger. Return the entity in
pendingstate immediately. - Set
client_max_body_size 10M;in nginx andupload_max_filesize/post_max_sizein PHP. Without it, large uploads silently die with a HTML error response that GraphQL clients mis-parse.
Bonus - Production launch checklist
Before you ship to prod:
-
- Introspection lets anyone dump your entire schema - free reconnaissance for an attacker. Keep it on in dev only.
-
- Compiling the schema from YAML/attributes on every request wastes CPU. Cache the compiled version so requests just load it.
-
- One endpoint means one choke point. Without per-IP/per-user limits a single client can hammer you. Use Symfony's RateLimiter component.
-
- Clients send a query hash instead of the full text (smaller payloads) and, as an allowlist, the server rejects any query it doesn't already know.
-
- When you can't pre-register queries (third-party clients), restrict the API to a vetted set so nobody can run arbitrary expensive ones.
-
- The client only sees a generic
INTERNAL_ERROR. Wire the formatter to your error tracker so you still capture the real stack trace.
- The client only sees a generic
-
- Catches removed fields or changed types before they ship and silently break every client that depends on them.
-
- Concurrency reveals N+1s and slow resolvers that single-request dev testing never surfaces.
-
- A DB-side log of slow queries surfaces the one resolver quietly killing your p99 in production.
-
- Assert a request stays under N SQL queries, so a newly introduced N+1 fails CI the day it lands - not the day it hits prod.
A CLAUDE.md snippet
If you're using Claude Code (or any AI coding assistant that reads project instruction files), drop this in so new GraphQL code follows the patterns above by default instead of re-introducing the traps:
## Symfony GraphQL - production rules
This project exposes GraphQL from Symfony. The schema is public attack
surface; resolvers run inside one HTTP endpoint with no per-route guards.
### Rules
- NEVER resolve a related entity by calling a repository directly inside a
resolver (`$repo->find($obj->getFooId())`). Use a DataLoader so IDs batch
into one query. Direct finds re-introduce N+1.
- All list fields MUST use cursor pagination (Relay-style `edges`/`pageInfo`),
never `LIMIT`/`OFFSET`, and MUST hard-cap `first` (max 100).
- Query complexity and depth limits stay configured. New expensive list
fields MUST declare a `complexity` cost in the schema.
- `enable_introspection` MUST be false in production config.
- Field-level authorization goes through Symfony Voters + `access`
expressions in the schema - NOT controllers. Unauthorized field reads
return `null`; do not throw. Reserve thrown errors for mutations and
serious auth violations.
- Exceptions safe to show the user MUST implement the `UserError` marker
interface. Everything else is logged and returned as a generic
`INTERNAL_ERROR` - never let a raw exception message reach the client.
- Mutations MUST return recoverable validation errors as data (a typed
`errors` array in the payload with stable `code`s), not via the GraphQL
`errors` array. The `errors` array is only for things the user can't
fix: auth failures, crashes, rate limits.
- File uploads use the `Upload` scalar. Validate MIME from file *content*
(not the client-supplied type), cap the size, and push heavy processing
(resize, EXIF strip, S3) to Messenger - never inline in the resolver.
- Add a Doctrine query-counter assertion in functional tests for the top
queries so a new N+1 fails CI the day it's introduced.
A short epilogue
None of these patterns are clever. They're all "the obvious thing once you've been bitten." Every one of the seven cost me at least one production incident before I internalized it. Hopefully this saves you a few of those.
About the author. Michal Campr is a backend engineer at one of Central Europe's largest real estate marketplaces, where he has built and maintained Symfony GraphQL APIs in production since 2016.