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.
Table of Contents
- 1. Why go GraphQL without API Platform at all
- 2. Schema definition with types, queries, and mutations
- 3. Binding resolvers as Symfony services via dependency injection
- 4. Executing queries through a dedicated controller
- 5. How this differs from API Platform's built-in GraphQL support
- 6. Performance and the N+1 problem in nested resolvers
- 7. Error handling and input validation in mutations
- 8. Schema caching and persisted queries for production
- 9. When skipping API Platform actually pays off
- 10. Summary
- 11. FAQ
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