Symfony Without API Platform: Integrating GraphQL with webonyx/graphql-php
AI generated
SF
{ }
Symfony · GraphQL · webonyx/graphql-php · API Design
Symfony Without API Platform
integrating GraphQL directly with webonyx/graphql-php

API Platform ships GraphQL support practically out of the box, but it comes bundled with a full resource, filter, and serialization stack that not every project needs. If you just want to put a lean GraphQL interface over an existing Symfony project, the base library webonyx/graphql-php often gets you there faster and with far less overhead, because schema and resolvers are defined directly and explicitly.

16 min read webonyx/graphql-php Symfony 7 · Dependency Injection

1. Why go GraphQL without API Platform at all

API Platform is the obvious choice when a project needs to serve both REST and GraphQL from day one and wants automatic resource discovery on top of Doctrine entities. The price is an extra abstraction layer of resource metadata, filters, paginators, and its own serialization pipeline, which in smaller or very specific projects creates more configuration overhead than it saves. If you already run a mature Symfony application with its own services, DTOs, and repositories, you would have to partially reshape that structure to fit API Platform's expectations instead of just reusing it as is.

The webonyx/graphql-php library is the reference implementation of the GraphQL specification for PHP and works entirely independently of Symfony or API Platform. You define a schema of types, queries, and mutations, wire in your own resolver functions, and decide yourself how those resolvers connect to existing services. In a Symfony project, that means resolvers can be plain Symfony services with constructor injection, which makes integrating with existing repository and service layers far more direct than going through API Platform resources.

2. Schema definition with types, queries, and mutations

A GraphQL schema consists of a query root type, optionally a mutation root type, and the object types they reference. With webonyx/graphql-php, types can either be defined declaratively as PHP classes extending ObjectType, or programmatically as instances built from a fields callback. For medium-sized schemas, the class-based approach is usually clearer, since each type lives in its own file and can be registered like a regular Symfony service instead of getting lost inside one giant schema file.

What matters is keeping a clean separation between the type definition, which only describes which fields exist and what type they return, and the resolver, which describes how a field value is actually produced. This separation lets you pair the same type definition with different resolvers, for example in-memory data for tests versus Doctrine-repository-backed resolution in production, without ever touching the schema itself.


<?php
declare(strict_types=1);

namespace App\GraphQL\Type;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use App\GraphQL\Resolver\ProductResolver;

/**
 * GraphQL type definition for a product.
 */
final class ProductType extends ObjectType
{
    public function __construct(private readonly ProductResolver $resolver)
    {
        parent::__construct([
            'name' => 'Product',
            'fields' => [
                'id' => Type::nonNull(Type::id()),
                'name' => Type::nonNull(Type::string()),
                'price' => Type::nonNull(Type::float()),
                'inStock' => [
                    'type' => Type::nonNull(Type::boolean()),
                    'resolve' => fn (array $root): bool =>
                        $this->resolver->isInStock((int) $root['id']),
                ],
            ],
        ]);
    }
}

3. Binding resolvers as Symfony services via dependency injection

The big advantage of a manual GraphQL integration is that resolvers are just plain Symfony services. A ProductResolver can get a ProductRepositoryInterface and a LoggerInterface injected through constructor property promotion, exactly like any other service. That means there's no special casing for GraphQL code, and existing business logic from REST controllers or console commands can be reused directly instead of being duplicated for an API-Platform-specific resource model.

For the types themselves to work as services with injected resolvers, they need to be registered in services.yaml and resolved through the container when the schema is assembled, rather than instantiated directly with new. A central TypeRegistry service encapsulates that resolution step, so adding a new type only requires writing the class and tagging it as a service, without touching the schema assembly code at all.

4. Executing queries through a dedicated controller

Unlike API Platform, which registers the GraphQL endpoint automatically, a manual integration needs its own controller for /graphql. That controller reads the query, variables, and operation name from the request body, calls GraphQL::executeQuery with the assembled schema, and returns the result as JSON. Error handling, for example for invalid queries or resolver exceptions, can be wired centrally into GraphQL's own error handling, which produces formatted error objects in the standard errors array.

Introspection, the ability for tools like GraphiQL or Apollo Studio to explore the schema automatically, needs no extra configuration since webonyx/graphql-php supports introspection queries natively. In production environments it's still worth making introspection toggleable behind a feature flag, so internal schema details aren't unnecessarily exposed to arbitrary clients.

5. How this differs from API Platform's built-in GraphQL support

API Platform's GraphQL support automatically generates queries and mutations from existing API resources, including filtering, sorting, and Relay-style cursor pagination. That saves a lot of boilerplate once resources are already modeled as API Platform entities, but it ties the GraphQL layer tightly to that resource model. Changes to the auto-generated schema, say a field that should be named differently in GraphQL than in the entity, require extra attributes and configuration on the resource itself.

With a manual webonyx/graphql-php integration there's no automatic derivation, but you get full control over every detail of the schema regardless of how the underlying data is modeled internally. That pays off especially when the GraphQL schema is meant to look deliberately different from the internal data model, for example because it describes a public API for third parties that must stay stable independently of internal refactorings.

6. Performance and the N+1 problem in nested resolvers

As soon as a resolver for a list field triggers its own database query per element, you get the classic N+1 problem, where a query for ten products and their categories quickly turns into eleven database round trips instead of two. The fix is a DataLoader pattern, where individual resolver calls within a single request cycle get collected and then executed together as one IN query, instead of answering each request immediately and separately.

In webonyx/graphql-php this can be implemented using the SyncPromiseAdapter together with a custom DataLoader object that caches load requests and only batches them once the promise is actually resolved. Planning for this optimization from the start avoids performance problems that are hard to diagnose later, since they only show up with deeply nested queries over large object sets and often stay hidden in development with small test datasets.

7. Error handling and input validation in mutations

Mutations are exactly where input validation matters most, since unlike queries they change state. Symfony's Validator component can be injected into a resolver without any friction, so input arrays first get mapped into a DTO and then validated against the familiar constraints before the actual business logic runs. On validation failure, webonyx/graphql-php lets you attach structured error details through a custom error class in the response's errors array, instead of returning only a generic failure message.

For technical errors, say an unreachable database, it's worth distinguishing expected business errors from unexpected exceptions. A central error formatter can log unexpected exceptions and return only a generic message to the client, while expected errors like a validation violation get passed through with concrete details the client can actually act on.

8. Schema caching and persisted queries for production

Building a large schema with many types and fields costs some time on every request if it's assembled entirely dynamically from services. In production, it's therefore worth caching the resolved schema structure so the expensive assembly happens once per deployment instead of on every request. Symfony's own Cache component works well here, since the assembled type objects can be stored under a fixed key.

Persisted queries add a further optimization: instead of sending the full query string, the client sends only a hash that the server has previously stored. This reduces request size and lets the server reject unknown or unapproved queries outright, which considerably shrinks the attack surface for arbitrarily complex, potentially expensive queries on publicly reachable GraphQL endpoints.

9. When skipping API Platform actually pays off

The manual route with webonyx/graphql-php pays off mainly when a mature service and repository structure already exists that you don't want to reshape around API Platform conventions, or when the GraphQL schema is deliberately meant to differ from the internal data model. Projects that serve GraphQL exclusively, with no REST at all, also save the overhead of configuring a framework whose REST capabilities would go entirely unused.

Conversely, API Platform is the better choice when REST and GraphQL need to be served from the same resources in parallel, and automatic filtering, pagination, and OpenAPI documentation save more time than they cost in flexibility. The decision is less about right or wrong and more a trade-off between maximum control and maximum convention-driven convenience, which plays out differently depending on project size and existing codebase.

Aspect webonyx/graphql-php alone API Platform GraphQL
Schema creation manual, full control automatic from resources
Service binding direct dependency injection via resource metadata
REST alongside not included shipped automatically
Learning curve GraphQL spec directly API Platform conventions
Best fit mature, custom-built services new, resource-oriented projects

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

GraphQL Without API Platform: Key Facts

Library

webonyx/graphql-php, the reference implementation of the GraphQL spec for PHP

Integration

resolvers as plain Symfony services with constructor injection

Control

full control over the schema regardless of the internal data model

Trade-off

no automatic REST support, but no API Platform overhead either

11. FAQ: GraphQL Without API Platform: Key Facts

1Do I need API Platform to use GraphQL in Symfony?
No. webonyx/graphql-php is a standalone library, independent of Symfony and API Platform, that can be fully integrated into a Symfony project through a custom controller and custom services.
2How are resolvers connected to Symfony services?
Resolvers are defined as plain Symfony services using constructor property promotion and get their dependencies, such as repositories or other services, injected normally through dependency injection.
3Does webonyx/graphql-php support mutations?
Yes. Alongside the query root type, a separate mutation root type can be defined whose fields are treated as change operations and typically run input validation before the actual processing.
4How do I solve the N+1 problem in nested GraphQL queries?
Through a DataLoader pattern that collects individual load requests within a single request cycle and then executes them together as one database query instead of serving each resolver call separately.
5Can I disable introspection in production?
Yes, through a custom feature flag in the controller that detects introspection queries by their operation name and rejects them before they reach schema execution.
6Is switching from API Platform GraphQL to webonyx/graphql-php worth it?
Only if concrete limitations of the automatic derivation, for example a schema that needs to diverge from the internal data model, actually become a real problem in the project. Otherwise API Platform's convention-driven convenience usually wins out.
7How do I handle validation errors in mutations?
Inputs are mapped into a DTO before processing and validated with the Symfony Validator. Errors are returned as structured details in the response's errors array through a custom GraphQL error class.
8Is schema caching necessary?
Usually unnoticeable for small schemas, but worthwhile for large schemas with many types and services, to avoid repeatedly rebuilding the type objects on every request.
9What are persisted queries and when do they help?
The client sends only a hash instead of the full query string. This helps mainly on publicly reachable endpoints, letting the server reject unknown, potentially expensive queries outright.
10Can I combine REST and the manual GraphQL integration in the same project?
Yes, without issue. The GraphQL controller runs as an additional route alongside existing REST controllers and can use the same services and repositories as they do.