Magento GraphQL Custom Resolver — A Complete Walkthrough
Adobe DevDocs ships a one-page resolver tutorial that stops the moment your query needs auth, cache control, or a real service contract behind it. This post builds a customerInsights resolver from zero on Magento 2.4.4 — 2.4.9: schema.graphqls with @doc and @cache(cacheable: false), a PHP class implementing Magento\Framework\GraphQl\Query\ResolverInterface, the di.xml wiring, the bin/magento setup:upgrade schema regeneration step, and the FPC trap that silently makes your resolver return stale data on every request after deploy.
A Magento GraphQL custom resolver is a PHP class that implements Magento\Framework\GraphQl\Query\ResolverInterface and is wired to a field declared in your module's etc/schema.graphqls. It is how you expose a service contract — customer insights, custom catalog data, a saved-cart endpoint — to any headless storefront on Magento 2.4.4 — 2.4.9 without writing a REST controller, without bypassing Magento's auth layer, and without losing the GraphQL FPC integration. This walkthrough builds one resolver from zero, customerInsights, with the schema, the PHP, the di.xml, the auth flow, the FPC trap, and the real query calls.[1]
REST vs GraphQL — for this case
This post is GraphQL-only, but the trade-off matters because half of all "custom resolver" tickets we audit at kishansavaliya.com would have been better as a REST endpoint. Pick the protocol first; the rest follows.
| Concern | REST (custom WebAPI) | GraphQL (custom resolver) |
|---|---|---|
| Auth | ACL via webapi.xml resources | Context currentUserId + $context->getExtensionAttributes() |
| Caching | Manual, per-route HTTP headers | Built-in via @cache directive + X-Magento-Cache-Id |
| Schema discovery | Swagger / OpenAPI | Introspection (__schema) |
| Field selection | All-or-nothing per endpoint | Client picks fields, server resolves only those |
| Versioning | /V1/, /V2/ URL prefix | @deprecated(reason: "") on a field |
| Headless storefront fit | Acceptable; needs HTTP plumbing | Native — Apollo / urql / PWA Studio expect it |
| FPC integration | Manual Varnish VCL | Out of the box on 2.4.7+ |
For a single field that drives a UI badge — lifetime value, reorder probability, last login — GraphQL wins because the headless storefront requests only the fields it needs in one round-trip. Build REST when you need file uploads, binary payloads, or external callbacks; otherwise default to GraphQL.
The full file tree before we touch anything
Five hand-written files ship in this module under app/code/Vendor/CustomerInsights/. Nothing in generated/ is touched.
app/code/Vendor/CustomerInsights/
├── etc/
│ ├── module.xml
│ ├── schema.graphqls
│ └── di.xml
├── Model/
│ └── CustomerInsightsService.php
├── Resolver/
│ └── CustomerInsights.php
├── registration.php
└── composer.jsonbin/magento setup:upgrade produces the merged schema.graphqls under generated/ — never edit it. If you reach for it, the schema source is wrong.
1. Declare the module
Every Magento module needs registration.php and etc/module.xml. Magento_GraphQl must appear in the sequence list so the resolver dispatcher boots first.
registration.php
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Vendor_CustomerInsights',
__DIR__
);etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Vendor_CustomerInsights">
<sequence>
<module name="Magento_GraphQl"/>
<module name="Magento_CustomerGraphQl"/>
</sequence>
</module>
</config>The Magento_CustomerGraphQl sequence entry gives the resolver access to the customer context. Skip it and $context->getUserId() returns null on authenticated requests.
2. Define the schema
The schema.graphqls file is the GraphQL SDL that Magento merges into the runtime schema. Every field needs an @doc directive — that is why every Adobe schema file you have read carries one.[2]
etc/schema.graphqls
type Query {
customerInsights: CustomerInsights
@resolver(class: "Vendor\\CustomerInsights\\Resolver\\CustomerInsights")
@doc(description: "Return per-customer insight metrics for the currently authenticated customer.")
@cache(cacheable: false)
}
type CustomerInsights @doc(description: "Per-customer lifetime metrics computed from the order grid.") {
customer_id: Int! @doc(description: "The currently authenticated customer's entity_id.")
lifetime_value: Float! @doc(description: "Sum of grand_total across all completed orders, in store currency.")
order_count: Int! @doc(description: "Number of orders in any state other than canceled.")
average_order_value: Float! @doc(description: "lifetime_value divided by order_count; zero if no orders.")
last_order_at: String @doc(description: "ISO 8601 timestamp of the most recent order; null for new customers.")
reorder_probability: Float! @doc(description: "Heuristic score 0.0–1.0 from days since last order and order cadence.")
}Four directives matter. @resolver(class: ...) binds the field to a PHP class — double-backslash the namespace. @doc ships in introspection and is what PWA Studio codegen, Apollo CLI, and ChatGPT-style SDKs read. @cache(cacheable: false) opts out of GraphQL FPC. The ! marks non-nullable — return null from PHP and GraphQL emits a validation error.
Every @doc string lands in the introspection payload. Write them as if a junior frontend developer will read them — because that is who reads introspection.
3. Write the service contract
Put the business logic in a service contract — a plain PHP class with one public entry point — not in the resolver itself. The resolver is a thin adapter that wires GraphQL plumbing to your service. This is the same separation Magento uses for every REST endpoint, and it lets you unit-test the service without booting GraphQL.
Model/CustomerInsightsService.php
<?php
declare(strict_types=1);
namespace Vendor\CustomerInsights\Model;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Api\Data\OrderInterface;
class CustomerInsightsService
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private SearchCriteriaBuilder $searchCriteriaBuilder
) {}
public function getForCustomer(int $customerId): array
{
$criteria = $this->searchCriteriaBuilder
->addFilter('customer_id', $customerId)
->addFilter('state', 'canceled', 'neq')
->create();
$orders = $this->orderRepository->getList($criteria)->getItems();
$count = count($orders);
$lifetimeValue = array_sum(array_map(
static fn(OrderInterface $o): float => (float)$o->getGrandTotal(),
$orders
));
$lastOrderAt = $count ? max(array_map(fn($o) => (string)$o->getCreatedAt(), $orders)) : null;
return [
'customer_id' => $customerId,
'lifetime_value' => round($lifetimeValue, 2),
'order_count' => $count,
'average_order_value' => $count ? round($lifetimeValue / $count, 2) : 0.0,
'last_order_at' => $lastOrderAt,
'reorder_probability' => $count ? 0.42 : 0.0,
];
}
}The service does not know GraphQL exists — test it with PHPUnit by mocking OrderRepositoryInterface and prove the math without booting GraphQL.
4. Write the resolver
The resolver class is where GraphQL plumbing meets your service. It implements Magento\Framework\GraphQl\Query\ResolverInterface, reads the authenticated customer from $context, and delegates to the service.
Resolver/CustomerInsights.php
<?php
declare(strict_types=1);
namespace Vendor\CustomerInsights\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\GraphQl\Model\Query\ContextInterface;
use Vendor\CustomerInsights\Model\CustomerInsightsService;
class CustomerInsights implements ResolverInterface
{
public function __construct(private CustomerInsightsService $service) {}
public function resolve(
Field $field,
$context,
ResolveInfo $info,
array $value = null,
array $args = null
): array {
/** @var ContextInterface $context */
if (!$context->getExtensionAttributes()->getIsCustomer()) {
throw new GraphQlAuthorizationException(
__('The current customer is not authorized for customerInsights.')
);
}
$customerId = (int)$context->getUserId();
if ($customerId <= 0) {
throw new GraphQlAuthorizationException(
__('A logged-in customer token is required.')
);
}
return $this->service->getForCustomer($customerId);
}
}Three things to note. The constructor uses PHP 8 property promotion — fine on Magento 2.4.4+ (PHP 8.1+) and 2.4.9 (PHP 8.3 / 8.4). GraphQlAuthorizationException emits a structured GraphQL error, never a 500 — clients branch on it. The return shape must match the SDL exactly: snake_case keys, non-null types respected. One missing key returns null with a validation error.
5. Wire di.xml when stitching onto an existing type
The schema above declared a top-level customerInsights field on Query — no di.xml needed. The story changes the moment you want to attach a field to an existing Magento type — for example, adding insights as a sub-field of the built-in Customer type that Magento_CustomerGraphQl ships.
Extend the schema
type Customer {
insights: CustomerInsights
@resolver(class: "Vendor\\CustomerInsights\\Resolver\\CustomerInsights")
@doc(description: "Per-customer insight metrics; nested under the existing Customer type.")
@cache(cacheable: false)
}Add a customer mutation list entry via di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\CustomerGraphQl\Model\Customer\GetCustomer">
<plugin name="vendor_customerinsights_attach"
type="Vendor\CustomerInsights\Plugin\AttachInsights"
sortOrder="100"/>
</type>
</config>The plugin's afterExecute attaches the insights array onto the customer model so the nested resolver receives it via $value. Schema stitching keeps the parent Customer type owned by Adobe and your additions in your module — that is what survives a minor upgrade.
6. Regenerate the merged schema
Magento merges every schema.graphqls across enabled modules on bin/magento setup:upgrade. The merged file lands in generated/code/Magento/Framework/GraphQl/ and is what the runtime reads.
bin/magento module:enable Vendor_CustomerInsights
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flushThe setup:di:compile step is what generates the proxy and interceptor classes for your resolver and service. Skip it in production mode and the resolver throws Class Vendor\CustomerInsights\Resolver\CustomerInsights\Interceptor not found on the first request.[3]
7. The request flow, end to end
Every GraphQL request on Magento 2.4.4 — 2.4.9 follows the same path. The client POSTs to /graphql with an Authorization: Bearer header; Magento\GraphQl\Controller\HttpRequestProcessor validates the HTTP envelope; the query is parsed and validated against the merged schema; Magento\Framework\GraphQl\Query\QueryProcessor::process() dispatches each field to its @resolver class; your resolver runs and returns an array; the framework walks the result against the SDL — every non-null field must be present; the response is serialized and, when @cache(cacheable: true) is set, stored under X-Magento-Cache-Id for the next identical request. Two common breakage points: forgetting Content-Type: application/json returns a 400, and parser errors are wrapped in a 200 response body, so curl -f will not catch them.
The FPC gotcha — read this before you ship
GraphQL responses are NOT cached by Varnish or Magento FPC by default. Adobe added per-field caching in 2.4.5+ via the @cache directive, but the default for any field without an explicit directive is cacheable: true with the response cache keyed on the request hash. That means an authenticated query that does not declare @cache(cacheable: false) gets its first response cached — and every customer hitting that field afterward receives the first customer's data until the cache is flushed.[4]
If your custom resolver returns per-customer data and you forget @cache(cacheable: false), the second customer to hit your store sees the first customer's order history. We have audited two production sites where this shipped to live.The rule
- Any field that depends on the authenticated customer — orders, addresses, insights, saved carts — must declare
@cache(cacheable: false). - Any field that depends on the store view but not the customer — categories, CMS blocks, configurable product options — can omit the directive; the default behavior is correct.
- Any field that is global and time-bounded — currency rates, store config — can declare
@cache(cacheable: true)explicitly with a shorter TTL via thecacheIdentityClassattribute.
Real queries against the live resolver
Two queries against a Magento 2.4.9 store. Both assume the customer has logged in and exchanged email + password for a token via the generateCustomerToken mutation.
Step 1: get the token
mutation {
generateCustomerToken(email: "customer@example.com", password: "swordfish") {
token
}
}curl -sS -X POST https://store.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { generateCustomerToken(email: \"customer@example.com\", password: \"swordfish\") { token } }"}'Step 2: call the resolver with the token
query {
customerInsights {
customer_id
lifetime_value
order_count
average_order_value
last_order_at
reorder_probability
}
}curl -sS -X POST https://store.example.com/graphql \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer 7n4kqz0mxhq2vp9c5btu3ywf6gj8ldso' \
-d '{"query":"{ customerInsights { customer_id lifetime_value order_count average_order_value last_order_at reorder_probability } }"}'The expected response
{
"data": {
"customerInsights": {
"customer_id": 14821,
"lifetime_value": 1247.50,
"order_count": 7,
"average_order_value": 178.21,
"last_order_at": "2026-04-18 10:22:14",
"reorder_probability": 0.42
}
}
}Drop the Authorization header and the response carries a graphql-authorization error with HTTP 200 — GraphQL packages errors in the body, never the status code. Your storefront must branch on response.errors.
Common failure modes
Cannot query field 'customerInsights' on type 'Query'
The schema was not re-merged. Run bin/magento setup:upgrade && bin/magento cache:flush. If the error persists, check generated/code/Magento/Framework/GraphQl/ exists and is writable.
The resolver runs but returns the previous customer's data
The @cache(cacheable: false) directive is missing. Add it, run bin/magento cache:clean full_page graphql_query, and the response is fresh per customer from the next request on.
Class ...\Interceptor not found
Production mode without bin/magento setup:di:compile. Run it; the resolver inherits the interceptor that Magento generates around every ObjectManager-instantiated class.
FAQ
Why use a GraphQL resolver instead of a custom REST endpoint?
GraphQL gives you field-level selection (the client picks which fields to compute), built-in introspection (auto-generated TypeScript types on the storefront), and FPC integration via @cache. REST wins for file uploads, webhook callbacks, and integrations with systems that already speak REST. For per-customer read endpoints feeding a storefront UI, GraphQL is almost always the right default on Magento 2.4.4 — 2.4.9.
How does the resolver know which customer is logged in?
The $context argument passed into resolve() implements Magento\GraphQl\Model\Query\ContextInterface. Calling $context->getUserId() returns the customer entity_id when a valid Authorization: Bearer header is present; $context->getExtensionAttributes()->getIsCustomer() returns true. Both come from the customer token validated by Magento_CustomerGraphQl earlier in the request lifecycle.
What is the difference between @cache(cacheable: false) and not setting @cache at all?
Without an explicit directive, the field inherits the default — cacheable with a request-hash key. @cache(cacheable: false) opts the field out entirely. @cache(cacheable: true, cacheIdentityClass: "...") opts in with a custom cache-key class. Per-customer data must use false, or the first response is served to every subsequent customer.
Does this resolver work on Adobe Commerce and Magento Open Source?
Yes — Magento\Framework\GraphQl ships in both editions, identical API. The only Commerce-specific quirk: if you stitch onto the Customer type, run bin/magento setup:upgrade twice to settle the schema merge order against CustomerSegment.
Can I version the resolver — for example, customerInsightsV2?
Yes — declare a second field on Query with a new resolver class and mark the old one @deprecated(reason: "Use customerInsightsV2") in the SDL. Clients migrate at their own pace; never break an existing field.
Where this fits in a Hyvä + Magewire stack
PWA Studio and Hyvä-React-Checkout query the resolver directly from the client; classic Hyvä (Alpine + Magewire) usually calls the service contract inline in PHP. Either way, the resolver gives you one source of truth — same service powers Apollo on the PWA, a partner integration, and a server-rendered Hyvä block. Most builds we ship through kishansavaliya.com take 12–20 hours from spec to deployed.
Related reading
- Magento 2.4.9 upgrade issues — the 5 universal traps
- 10 Alpine.js patterns every Hyvä developer needs
- Hyvä theme development service
References
- Adobe Developer Documentation, GraphQL Developer Guide — Custom Attributes and Resolvers. Reference for
ResolverInterface,$context, and the merged schema lifecycle on Magento 2.4.4 — 2.4.9. - graphql.org, GraphQL specification (October 2021). Reference for directives, non-null types, and the introspection schema that
@docpopulates. - Adobe Developer Documentation, Setup CLI Reference — setup:di:compile. Reference for the interceptor generation that
ObjectManagerrequires in production mode. - Adobe Developer Documentation, GraphQL Caching — @cache directive. Reference for the per-field caching behavior added in Magento 2.4.5 and refined in 2.4.7 / 2.4.9.
- Production resolver engagements via kishansavaliya.com, 2024 — 2026. Patterns extracted from custom resolvers shipped across Adobe Commerce and Magento Open Source 2.4.4 — 2.4.9.
I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I ship fixed-scope GraphQL resolver work — per-customer dashboards, headless catalog extensions, B2B account endpoints — with schema, service contract, FPC wiring, and integration tests. Fixed quote from $499 audit · $2,499 sprint · ~16h @ $25/hr. See hire me.