Michal Campr
EST · 2008
Articles

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

  1. Kill N+1 with a DataLoader
  2. Cap query complexity and depth
  3. Field-level authorization with Voters
  4. Two-tier error handling
  5. Errors-as-data for mutations
  6. Cursor pagination, not offset
  7. File uploads via the multipart spec
  8. 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:

  1. Parse errors[].message strings,
  2. Guess which field they map to,
  3. 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:


Bonus - Production launch checklist

Before you ship to 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.