API Platform GraphQL: Custom Resolvers, Mutations and Subscriptions
AI generated
SF
{ }
Symfony · API Platform · GraphQL Resolver · PHP 8.4
API Platform GraphQL: custom resolvers, mutations and subscriptions
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.

18 min read QueryItemResolverInterface · Mutation Input · Mercure Subscription API Platform 4 · Symfony 7 · PHP 8.4

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.

11. FAQ: GraphQL resolvers in API Platform

1When to use a custom resolver?
For aggregated data or mutations with more than a simple field change, CRUD is covered by the default schema.
2Query vs mutation resolver?
Query resolver for reads via QueryItemResolverInterface, mutation resolver for writes via MutationResolverInterface.
3Why a dedicated input class?
Keeps the schema self documenting, a client sees exactly the fields needed instead of the whole entity.
4Validation with custom resolver?
Yes, constraints on the input class run normally before the resolver call, check business rules additionally inside it.
5How do subscriptions work?
Through the mercure argument, pushing an event via the Mercure hub to subscribed clients on every change.
6N plus 1 solved automatically?
No, solve actively via eager loading or a DataLoader pattern, GraphQL creates more unpredictable access patterns than REST.
7Access checks per field?
Yes, a resolver can selectively return only a field as null, this check must live inside the resolver itself.
8How to test a resolver?
Unit test with test doubles plus a functional test with a real query or mutation against the GraphQL endpoint.
9Does a resolver replace the default schema?
No, both exist side by side, CRUD stays automatic, resolvers add complex cases deliberately.
10Distinguishing errors from validation?
Validation errors automatic from constraints, business errors as a custom exception with its own error code in the resolver.