when the automatic CRUD schema is no longer enough
API Platform automatically generates queries and mutations from every resource, but as soon as an action combines several resources or real time updates are needed, you need a custom GraphQL resolver. This article shows how query resolvers, mutation resolvers with dedicated input classes, and Mercure based subscriptions work together in API Platform.
Table of contents
- 1. When the automatic GraphQL schema is not enough
- 2. A custom query resolver for aggregated data
- 3. Mutation resolvers with dedicated input and output classes
- 4. Validation and error handling in mutations
- 5. Enabling real time subscriptions via Mercure
- 6. Avoiding N plus 1 problems in nested queries
- 7. Field level access checks inside a resolver
- 8. Testing GraphQL resolvers and mutations
- 9. Default schema versus custom resolver compared
- 10. Summary
- 11. FAQ
1. When the automatic GraphQL schema is not enough
Once GraphQL support is enabled in API Platform, the library automatically generates item and collection queries as well as create, update, and delete mutations from every class annotated with #[ApiResource]. For simple CRUD resources, this already provides a complete schema without any manually written resolver. But as soon as a query needs to merge data from several sources or a mutation needs to trigger more than a simple field change, this automatic schema is no longer enough.
A custom resolver in API Platform is conceptually the same principle as a State Provider, just tailored to GraphQL: instead of asking Doctrine directly, a PHP class takes control over resolving a field or an operation. API Platform distinguishes between query resolvers for reads and mutation resolvers for writes, both registered through attributes on the resource.
The third building block, subscriptions, goes beyond classic request response GraphQL: instead of a client repeatedly polling, a WebSocket like connection stays open through Mercure and pushes updates as soon as a resource changes. Together these three mechanisms cover the cases where plain CRUD GraphQL reaches its limits.
2. A custom query resolver for aggregated data
A query resolver implements QueryItemResolverInterface for an item query or QueryCollectionResolverInterface for a collection query. It is registered through the resolver argument inside the GraphQlOperation attribute. A typical use case is a dashboard field that aggregates figures from several entities, for example a customer's total revenue and order count in a single GraphQL field, instead of forcing the client to combine several separate queries.
The resolver gets access to the resolved base object and the GraphQL context, including the requested fields. That allows targeted optimization: if an expensive aggregated field is not even requested in the current query, the resolver can skip the associated computation entirely instead of always running it just in case.
<?php
declare(strict_types=1);
namespace App\Resolver;
use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
use ApiPlatform\Metadata\Operation;
use App\Repository\OrderRepository;
/**
* Custom GraphQL resolver that aggregates order statistics
* for a customer into a single computed field.
*/
final readonly class CustomerStatsResolver implements QueryItemResolverInterface
{
public function __construct(
private OrderRepository $orders,
) {
}
public function __invoke(mixed $item, array $context): mixed
{
$stats = $this->orders->aggregateForCustomer($item->id);
$item->totalRevenue = $stats['revenue'];
$item->orderCount = $stats['count'];
return $item;
}
}
3. Mutation resolvers with dedicated input and output classes
A mutation resolver class implements MutationResolverInterface and is used for actions that go beyond a simple field change, for example cancel order with a refund or redeem discount code with a stock check. Instead of accepting the entire entity as input, a dedicated input class should be defined with exactly the fields that this one mutation actually needs.
This separation of input, entity, and output keeps the GraphQL schema self documenting: a client sees in the schema introspection exactly which fields a mutation expects, without being distracted by optional entity fields irrelevant to this use case. API Platform automatically generates the matching GraphQL input type from the input class.
<?php
declare(strict_types=1);
namespace App\Resolver;
use ApiPlatform\GraphQl\Resolver\MutationResolverInterface;
use App\Entity\Order;
use App\Repository\OrderRepository;
use App\Service\RefundService;
use Doctrine\ORM\EntityManagerInterface;
/**
* Mutation resolver that cancels an order and triggers a refund,
* going beyond a simple field update.
*/
final readonly class CancelOrderMutationResolver implements MutationResolverInterface
{
public function __construct(
private OrderRepository $orders,
private RefundService $refunds,
private EntityManagerInterface $entityManager,
) {
}
public function __invoke($item, array $context): Order
{
$reason = $context['args']['input']['reason'] ?? null;
$item->status = 'cancelled';
$item->cancellationReason = $reason;
$this->refunds->issueRefund($item);
$this->entityManager->flush();
return $item;
}
}
4. Validation and error handling in mutations
Even with a custom mutation resolver, the configured Symfony Validator constraints on the input class run normally before the resolver is invoked. Still, it is important to explicitly check business rules that cannot be expressed as a simple constraint inside the resolver, for example whether an order can still be cancelled at all given its current status.
For error cases that are not a plain validation violation, the resolver throws a custom exception, translated by a GraphQL error formatter into a structured GraphQL error response with a matching error code. A client can then use the code to distinguish whether a cancellation failed because of the wrong status or because of a technical error, instead of receiving only a generic error message.
5. Enabling real time subscriptions via Mercure
API Platform offers native GraphQL subscriptions, internally realized through the Mercure hub instead of requiring its own WebSocket infrastructure. A subscription is enabled through the mercure argument on the resource attribute, so API Platform automatically publishes an update event via Mercure on every change, which subscribed GraphQL clients receive in real time.
The big advantage over classic polling: the client no longer has to re query the resource at fixed intervals, it gets changes pushed immediately as soon as the underlying State Processor completes the update. For dashboards, order status tracking, or chat like features, this significantly reduces both server load and perceived latency.
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Subscription;
/**
* Enables a GraphQL subscription pushed through Mercure
* whenever the shipment status changes.
*/
#[ApiResource(
mercure: true,
graphQlOperations: [
new Subscription(),
new Mutation(name: 'update'),
],
)]
final class ShipmentStatus
{
public string $id;
public string $status;
public \DateTimeImmutable $updatedAt;
}
6. Avoiding N plus 1 problems in nested queries
GraphQL makes it trivial for clients to formulate deeply nested queries, for example a list of orders including each customer and their address. Without care, every nested relation results in its own database query per element of the outer collection, the classic N plus 1 problem, which occurs even more unpredictably in GraphQL than in fixed REST endpoints because of the free field selection.
The solution lies in eager loading via Doctrine extensions or in a DataLoader pattern that batches similar requests within a single GraphQL request and resolves them in a single database query. API Platform itself does not solve this automatically, a custom query resolver or an adapted Doctrine extension is necessary here to keep nested GraphQL queries performant.
7. Field level access checks inside a resolver
Unlike REST, where an entire resource is typically protected by a single security voter, GraphQL allows fine grained access checks per field. A resolver can, for example, check whether the currently authenticated user is allowed to see the aggregated revenue field of a foreign customer, and selectively return only that one field as null when authorization is missing, instead of rejecting the entire query.
This granularity is an advantage over REST, but it also carries more responsibility: every custom resolver has to implement the access check itself, because the #[ApiResource(security:)] attribute only applies at the level of the whole operation, not at the field level inside a custom resolver.
8. Testing GraphQL resolvers and mutations
A query resolver or mutation resolver is a normal Symfony service and can therefore be checked as a plain unit test without a GraphQL context, by instantiating the resolver directly with test doubles for its dependencies. That covers the actual logic quickly and in isolation.
For the complete path including schema validation, a functional test sends a real GraphQL query or mutation through the test client to the /graphql endpoint and checks the JSON response including any errors array entries. Both levels together ensure that both the resolver logic and its integration into the generated GraphQL schema work correctly.
9. Default schema versus custom resolver compared
The table below shows when the automatically generated GraphQL schema is enough and when a custom resolver becomes necessary.
| Use case | Default GraphQL schema | Custom resolver | Recommendation |
|---|---|---|---|
| Simple CRUD on an entity | Fully sufficient | Unnecessary overhead | Use the default schema |
| Aggregated metrics | Not achievable | Query resolver required | Custom resolver for dashboards |
| Multi step action like cancellation | Only a simple field change | Mutation resolver with input class | Custom resolver for business logic |
| Real time updates | Only through polling | Mercure subscription | Subscription for dashboards and tracking |
| Field level access checks | Only operation wide | Implementable inside the resolver | Custom resolver for sensitive fields |
The choice between an automatically generated GraphQL schema and a custom resolver follows the same pattern as with REST resources: the default path stays for the simple case, while custom resolvers step in wherever aggregation, multi step business logic, or real time requirements go beyond plain CRUD.
Mironsoft
Symfony and API Platform architecture for demanding APIs
Need GraphQL resolvers and real time subscriptions for your project?
We design custom GraphQL resolvers for aggregations and complex mutations, integrate Mercure subscriptions for real time updates, and fix N plus 1 problems in nested queries.
GraphQL architecture
Query and mutation resolvers for complex use cases
Real time features
Mercure subscriptions for dashboards and status tracking
Performance tuning
N plus 1 analysis and DataLoader pattern for GraphQL queries
10. Summary
The automatically generated GraphQL schema of API Platform fully covers simple CRUD, but for aggregated data you need a custom query resolver, and for multi step business logic a mutation resolver with a dedicated input class. Real time requirements can be handled through Mercure subscriptions without building your own WebSocket infrastructure.
Anyone who consistently uses custom GraphQL resolvers for cases that go beyond plain CRUD, while keeping N plus 1 problems and field level access checks in mind, ends up with a GraphQL schema that stays both powerful and performant. The default schema and custom resolvers never exclude each other, they complement each other within the very same API Platform project.
GraphQL resolvers in API Platform: the essentials
Query resolvers
QueryItemResolverInterface for aggregated or computed fields beyond simple Doctrine access.
Mutation resolvers
MutationResolverInterface with a dedicated input class for multi step business logic.
Mercure subscriptions
Real time updates without polling, enabled directly through the mercure argument on the resource attribute.
N plus 1 and security
Eager loading against nested query problems, field level access checks directly inside the resolver.