← Back to blog

Magento 2 GraphQL: Add a Custom Query and Resolver

Rodrigo Biazzi

Sooner or later a headless storefront, a PWA or a mobile app needs data that the core Magento GraphQL schema does not expose. The good news is that adding your own query is a small amount of code. The part people get wrong is not the resolver itself but what sits around it: input validation, authorization, store context and caching.

This article walks through a complete custom query for Magento Open Source or Adobe Commerce 2.4. The example module is Vendor_StoreLocator and it exposes a single physical store by its code. Swap the domain for whatever you need, the structure stays the same.

Module skeleton

A GraphQL extension is a normal module. It needs registration.php, etc/module.xml and a file named etc/schema.graphqls. Magento merges every schema.graphqls from enabled modules into one schema, so you never edit core files to add a field or a query.

Declare a sequence on Magento_GraphQl so your schema is merged after the base types exist.

<?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_StoreLocator">
        <sequence>
            <module name="Magento_GraphQl"/>
            <module name="Magento_StoreGraphQl"/>
        </sequence>
    </module>
</config>

A common convention in core is to keep GraphQL code in a separate module, for example Vendor_StoreLocatorGraphQl, that depends on the domain module. That keeps the domain logic usable from REST, admin and cron without dragging GraphQL classes along. For a small project one module is fine.

Writing the schema

The schema file declares the query, its arguments and the types it returns. Two directives do the wiring: @resolver points to the PHP class that returns the data and @cache points to the class that builds cache tags. Note the double backslashes in class names inside the string.

type Query {
    physicalStore(
        code: String! @doc(description: "Unique code of the physical store")
    ): PhysicalStore
        @resolver(class: "Vendor\\StoreLocator\\Model\\Resolver\\PhysicalStore")
        @cache(cacheIdentity: "Vendor\\StoreLocator\\Model\\Resolver\\PhysicalStore\\Identity")
        @doc(description: "Return a physical store by its code")
}

type PhysicalStore @doc(description: "A physical store location") {
    id: Int!
    code: String!
    name: String
    city: String
    opening_hours: [OpeningHours]
}

type OpeningHours {
    day: String
    opens: String
    closes: String
}

A few things worth knowing about the schema:

  • Core fields use snake_case. Stick to it so clients see one consistent style.
  • Only the top level field needs a resolver. Nested fields are read from the array the parent resolver returns, using the field name as the array key. You can add a resolver to a nested field when it needs its own logic.
  • If you want to add a field to an existing type, declare the type again in your file with only the new field. Magento merges the definitions. For interfaces such as ProductInterface, add the field to the interface and to the concrete types that implement it.
  • Use @doc generously. Frontend developers read the schema through introspection, and those descriptions are the only documentation they will see.

The resolver class

A resolver implements Magento\Framework\GraphQl\Query\ResolverInterface, which has one method, resolve. It receives the field definition, the request context, the resolve info, the parent value and the arguments.

<?php
declare(strict_types=1);

namespace Vendor\StoreLocator\Model\Resolver;

use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Vendor\StoreLocator\Api\PhysicalStoreRepositoryInterface;

class PhysicalStore implements ResolverInterface
{
    public function __construct(
        private readonly PhysicalStoreRepositoryInterface $repository
    ) {
    }

    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        $code = trim((string)($args['code'] ?? ''));
        if ($code === '') {
            throw new GraphQlInputException(__('"code" must not be empty.'));
        }

        $storeId = (int)$context->getExtensionAttributes()->getStore()->getId();

        try {
            $store = $this->repository->getByCode($code, $storeId);
        } catch (NoSuchEntityException $e) {
            throw new GraphQlNoSuchEntityException(__($e->getMessage()), $e);
        }

        return [
            'id' => (int)$store->getId(),
            'code' => $store->getCode(),
            'name' => $store->getName(),
            'city' => $store->getCity(),
            'opening_hours' => $store->getOpeningHours(),
        ];
    }
}

The repository here is part of the example module. The resolver should be thin. It validates input, reads context, calls a service and maps the result to an array that matches the schema. Business rules belong in the service layer, where they can be tested without a GraphQL request.

Use the GraphQL exception classes

Magento converts exceptions into the errors array of the response. The classes in Magento\Framework\GraphQl\Exception are marked as client safe, so their message reaches the client. The ones you will use most:

  • GraphQlInputException for bad or missing arguments.
  • GraphQlNoSuchEntityException when the requested record does not exist.
  • GraphQlAuthorizationException when the caller is not allowed to see the data.

Any other exception becomes a generic internal error for the client and is logged on the server. That is what you want for real failures, but it makes expected cases like "not found" look like bugs, so map them explicitly.

Store context and authorization

The $context argument implements Magento\GraphQl\Model\Query\ContextInterface. Through its extension attributes you get the current store, resolved from the Store header, and whether the caller is a logged in customer. Always use this store instead of the default store from StoreManagerInterface, otherwise a multi store setup will return the wrong data.

For customer data, check the context before doing anything else:

if (!$context->getExtensionAttributes()->getIsCustomer()) {
    throw new GraphQlAuthorizationException(__('The current customer is not authorized.'));
}
$customerId = (int)$context->getUserId();

Never accept a customer id as an argument and trust it. The id must come from the token, which is what getUserId() gives you.

Caching with a cache identity

Query requests sent with the GET method can be stored in the full page cache, Varnish or the built in cache. For that to be safe, Magento needs to know which entities a response depends on, so it can purge the cached response when one of them changes. That is the job of the identity class referenced by @cache(cacheIdentity: ...).

<?php
declare(strict_types=1);

namespace Vendor\StoreLocator\Model\Resolver\PhysicalStore;

use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;

class Identity implements IdentityInterface
{
    private const CACHE_TAG = 'vendor_physical_store';

    public function getIdentities(array $resolvedData): array
    {
        if (empty($resolvedData['id'])) {
            return [];
        }

        return [self::CACHE_TAG, self::CACHE_TAG . '_' . $resolvedData['id']];
    }
}

The tags only help if something purges them. Your model should implement Magento\Framework\DataObject\IdentityInterface and return the same tags from its getIdentities() method. When the entity is saved through a resource model that triggers the clean cache events, the tags are invalidated and Varnish drops the matching responses. If your data is written by an import or a direct SQL query, you must clean those tags yourself, for example with Magento\Framework\App\Cache\TypeListInterface or by dispatching clean_cache_by_tags with the entity.

When not to cache

Anything that depends on the customer, the cart or a secret must not be shared between users. Mark those queries with @cache(cacheable: false). Core does the same for customer and cart queries. Being explicit removes any doubt when someone later changes the client to send GET requests.

Testing the query

After enabling the module, run the usual commands and then call the endpoint. The schema is cached, so a change in schema.graphqls is not visible until the cache is cleaned.

bin/magento module:enable Vendor_StoreLocator
bin/magento setup:upgrade
bin/magento cache:flush

curl -s https://shop.test/graphql \
  -H 'Content-Type: application/json' \
  -H 'Store: default' \
  -d '{"query":"{ physicalStore(code: \"downtown\") { id name city opening_hours { day opens closes } } }"}'

To check caching, send the same query as GET with the query string encoded and look at the response headers. With Varnish in front you should see a cache hit on the second call. Then save the entity in the admin and confirm the next call is a miss.

For automated tests, Magento has an API functional test framework where test classes extend Magento\TestFramework\TestCase\GraphQlAbstract and send queries with its graphQlQuery() method. Even a single test that runs the query against a fixture catches most schema and mapping mistakes.

Performance notes

A resolver that runs a query per item becomes a problem as soon as it is used inside a list. If your field lives on a type that appears many times in one response, such as a field on products in a category listing, look at Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface or the deferred value pattern used in core. They let you collect all ids first and load them in one query.

Also look at ResolveInfo before loading heavy data. Its getFieldSelection() method tells you which child fields the client asked for, so you can skip loading opening hours when nobody requested them.

Checklist before shipping

  • Schema uses snake_case and every field has a @doc description.
  • Arguments are validated and errors use the GraphQL exception classes.
  • Store comes from the context, customer id comes from the token.
  • Cacheable queries have an identity class and the model emits the same tags.
  • Private data is marked as not cacheable.
  • No query per item inside lists.

FAQ

Where does the schema.graphqls file go in a Magento 2 module?

It goes in the etc folder of the module, as etc/schema.graphqls. Magento merges the files from all enabled modules into one schema, so you can add new queries and extend existing types without touching core code.

Why does my new GraphQL field not show up after I change the schema?

The merged schema is cached. Run bin/magento cache:flush after every change to schema.graphqls, and make sure the module is enabled and setup:upgrade has run.

Are Magento GraphQL queries cached by Varnish?

Queries sent with GET can be cached by Varnish or the built in full page cache. Mutations and POST requests are not cached. Your query needs a cache identity class so Magento can purge the right responses when data changes.

Should I put business logic in the resolver?

No. Keep the resolver thin. Validate input, read the context, call a service or repository and map the result to the schema. The logic stays testable and reusable from REST, admin and cron.

I am Rodrigo Biazzi, a Magento Certified Developer working with Magento and Adobe Commerce for more than 12 years. I am available for freelance Magento work, from single fixes to longer projects. If this is the kind of problem you have on your store, get in touch.

Related articles