Magento 2 GraphQL: Custom Queries & Mutations Guide

Magento 2 GraphQL Custom Queries & Mutations Guide

Magento 2 GraphQL: Custom Queries & Mutations Guide

Building a headless Adobe Commerce storefront relies on GraphQL. It serves as the contract between your frontend and backend. The core schema covers products, carts, and customers well. Custom needs differ. Loyalty points, B2B quote requests, and bespoke product configurators require custom modules.

This guide skips the "what is GraphQL" preamble. It walks you through building production-ready Magento 2 GraphQL custom queries and mutations inside a real module—from schema declaration to resolver logic to access control enforcement. All examples target Magento 2.4.x and PHP 8.2+.

---

What You'll Need

- Magento 2.4.6+ (local setup with Docker or Warden both work fine) - PHP 8.2+ (required for constructor property promotion and typed properties used throughout) - Composer 2.x (dependency management for your custom module) - Basic familiarity with Magento module structure (registration, di.xml, service contracts) - A GraphQL client for testing — Altair or the built-in /graphql playground

Key Terminology

Before diving in, four terms appear throughout this guide. Keep these definitions handy.

| Term | Definition | |---|---| | ResolverInterface | The PHP interface every Magento GraphQL resolver must implement (Magento\Framework\GraphQl\Query\ResolverInterface) | | Field | A GraphQL schema field object passed to resolve(); carries metadata about the field being resolved | | ResolveInfo | Contains query execution context—selected fields, path, and schema structure | | cacheIdentity | A class that generates the cache key for a query result; prevents customers from sharing cached responses | | GraphQlAuthorizationException | Thrown when a user lacks permission; returns a correct authorization error to the client |

---

1. Setting Up Your GraphQL Development Environment in Magento 2

Before writing a single resolver, configure your dev environment correctly. Magento's GraphQL layer has quirks that waste hours if you ignore them upfront.

Disable full-page cache during development. Magento caches GraphQL responses by default. Stale cache will confuse debugging.

bin/magento cache:disable full_page

bin/magento cache:disable block_html

Enable developer mode. Error messages in production mode are sanitized into uselessness.

bin/magento deploy:mode:set developer

Point your GraphQL client at the right endpoint. The default endpoint is https://your-store.test/graphql. No path prefix, no version segment—just /graphql.

With your environment ready, create the module structure. This guide uses the module Unomage_LoyaltyPoints.

app/code/Unomage/LoyaltyPoints/

├── etc/ │ ├── module.xml │ ├── di.xml │ └── schema.graphqls ├── Model/ │ ├── Resolver/ │ │ ├── LoyaltyBalance.php │ │ └── RedeemPoints.php │ └── LoyaltyService.php ├── registration.php └── composer.json

First, register your module. The registration.php file tells Magento the module exists:

// registration.php

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register( ComponentRegistrar::MODULE, 'Unomage_LoyaltyPoints', __DIR__ );

Next, declare the module and its load order. The sequence block ensures Magento loads the GraphQL and CustomerGraphQL modules before yours, which prevents resolver registration failures at runtime:

<!-- 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="Unomage_LoyaltyPoints"> <sequence> <module name="Magento_GraphQl"/> <module name="Magento_CustomerGraphQl"/> </sequence> </module> </config>

Run bin/magento setup:upgrade after placing the files. Your environment is configured and your module skeleton is in place.

Pro tip: Install any SMTP module that logs outgoing emails during dev. Loyalty modules often trigger transactional emails. Catch those without actually sending them.

---

2. Building Custom GraphQL Queries with Resolvers

Key Concepts: Schema Annotations

Three schema annotations appear in this section. The @resolver directive maps a field to a PHP class. The @cache directive enables response caching for that field. The cacheIdentity class inside @cache generates a unique cache key per customer—without it, all customers share the same cached response.

Schema Declaration

The schema.graphqls file is the foundation of your GraphQL module. Magento automatically discovers schema files from etc/schema.graphqls in any active module—no additional registration required.

# etc/schema.graphqls

type Query { loyaltyBalance( customer_id: Int @doc(description: "Customer ID") ): LoyaltyBalanceOutput @resolver(class: "Unomage\\LoyaltyPoints\\Model\\Resolver\\LoyaltyBalance") @doc(description: "Returns the loyalty points balance for an authenticated customer") @cache(cacheIdentity: "Unomage\\LoyaltyPoints\\Model\\Resolver\\Identity\\LoyaltyBalanceIdentity") }

type LoyaltyBalanceOutput { points_balance: Int! @doc(description: "Current redeemable points balance") tier_name: String @doc(description: "Current loyalty tier name") expiry_date: String @doc(description: "Date when points expire, ISO 8601 format") }

Implementing the Resolver

Every resolver implements ResolverInterface. This requires a single resolve() method. The method receives the field definition, execution context, and the caller's arguments.

Now that the schema defines the query, implement the resolver class:

<?php

// Model/Resolver/LoyaltyBalance.php declare(strict_types=1);

namespace Unomage\LoyaltyPoints\Model\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 Unomage\LoyaltyPoints\Model\LoyaltyService;

class LoyaltyBalance implements ResolverInterface { public function __construct( private readonly LoyaltyService $loyaltyService, ) {}

public function resolve( Field $field, // Metadata about the schema field being resolved $context, // GraphQL execution context; contains customer auth state ResolveInfo $info, // Query structure and selected fields array $value = null, // Parent field value (null for root queries) array $args = null // Arguments passed by the caller in the query ): array { // Reject unauthenticated requests before touching any data if (!$context->getExtensionAttributes()->getIsCustomer()) { throw new GraphQlAuthorizationException( __('The current customer is not authorized to view loyalty points.') ); }

$customerId = (int) $context->getUserId(); $balance = $this->loyaltyService->getBalance($customerId);

return [ 'points_balance' => $balance->getPointsBalance(), 'tier_name' => $balance->getTierName(), 'expiry_date' => $balance->getExpiryDate()?->format('c'), ]; } }

Finally, register the service binding in di.xml. The preference element maps the interface to its implementation:

<!-- etc/di.xml -->

<?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/di.xsd"> <preference for="Unomage\LoyaltyPoints\Api\LoyaltyServiceInterface" type="Unomage\LoyaltyPoints\Model\LoyaltyService"/> </config>

Warning: Never inject \Magento\Framework\App\RequestInterface into a resolver to read POST body data. Arguments arrive through $args. Injecting the request object breaks under concurrent requests in unpredictable ways.

Test the query in your GraphQL client:

query {

loyaltyBalance { points_balance tier_name expiry_date } }

Run bin/magento cache:flush after any schema change. Magento caches the compiled schema—changes won't appear until that cache clears.

Your query resolver is complete. Next, follow the same pattern for mutations.

---

3. Creating and Validating Custom Mutations

Mutations follow the same resolver pattern but live under a Mutation type in the schema. There is no separate mutation interface—resolvers use ResolverInterface throughout.

Add the mutation schema, then implement its resolver:

# Add to etc/schema.graphqls

type Mutation { redeemLoyaltyPoints( input: RedeemLoyaltyPointsInput! ): RedeemLoyaltyPointsOutput @resolver(class: "Unomage\\LoyaltyPoints\\Model\\Resolver\\RedeemPoints") @doc(description: "Redeem loyalty points against an active cart") }

input RedeemLoyaltyPointsInput { cart_id: String! @doc(description: "Masked cart ID") points_to_redeem: Int! @doc(description: "Number of points to redeem") }

type RedeemLoyaltyPointsOutput { success: Boolean! message: String remaining_balance: Int cart: Cart @doc(description: "Updated cart after redemption") }

Now that the schema defines the mutation, implement the resolver class:

<?php

// Model/Resolver/RedeemPoints.php declare(strict_types=1);

namespace Unomage\LoyaltyPoints\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field; use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Unomage\LoyaltyPoints\Model\LoyaltyService;

class RedeemPoints implements ResolverInterface { public function __construct( private readonly LoyaltyService $loyaltyService, ) {}

public function resolve( Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ): array { if (!$context->getExtensionAttributes()->getIsCustomer()) { throw new GraphQlAuthorizationException(__('Guests cannot redeem loyalty points.')); }

$input = $args['input'] ?? []; $this->validateInput($input);

$customerId = (int) $context->getUserId(); $cartId = $input['cart_id']; $points = (int) $input['points_to_redeem'];

$result = $this->loyaltyService->redeemPoints($customerId, $cartId, $points);

return [ 'success' => $result->isSuccess(), 'message' => $result->getMessage(), 'remaining_balance' => $result->getRemainingBalance(), 'cart' => ['model' => $result->getCart()], ]; }

private function validateInput(array $input): void { if (empty($input['cart_id'])) { throw new GraphQlInputException(__('cart_id is required.')); }

if (!isset($input['points_to_redeem']) || $input['points_to_redeem'] < 1) { throw new GraphQlInputException(__('points_to_redeem must be a positive integer.')); } } }

A note on the cart return type. Returning Cart from a custom mutation isn't trivial. Passing ['model' => $cartModel] signals Magento's child resolvers to hydrate the full cart object. Your output type references an existing core type, and Magento handles nested resolution transparently.

Pro tip: Throw GraphQlInputException for bad user input and GraphQlNoSuchEntityException for missing records. Using the wrong exception type returns incorrect HTTP status codes and confuses frontend error handling.

---

4. Authentication, Authorization, and Schema Security

The Magento 2 GraphQL resolver receives a $context object carrying customer authentication state. Here is how it breaks down:

| Context Method | What It Tells You | |---|---| | $context->getUserId() | Customer ID (0 if guest) | | $context->getUserType() | Integer — 0 = guest, 1 = customer, 2 = admin | | $context->getExtensionAttributes()->getIsCustomer() | Boolean shorthand for logged-in customer check |

For customer-only operations, gate on getIsCustomer(). For admin-only operations, check getUserType() === 2.

Access control for admin operations uses the standard etc/acl.xml declaration:

<!-- etc/acl.xml -->

<?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd"> <acl> <resources> <resource id="Magento_Backend::admin"> <resource id="Unomage_LoyaltyPoints::manage" title="Manage Loyalty Points" sortOrder="100"/> </resource> </resources> </acl> </config>

For customer token-based auth, the client must send a bearer token in the Authorization header:

Authorization: Bearer eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ...

Generate one via the standard mutation:

mutation {

generateCustomerToken(email: "[email protected]", password: "Pass123!") { token } }

Warning: Never trust customer IDs passed as mutation arguments. A logged-in customer could supply another customer's ID. Always derive identity from $context->getUserId(). Skipping this check is the most common security mistake in custom GraphQL modules.

---

5. Testing and Debugging GraphQL Endpoints in Adobe Commerce

Start with schema introspection to confirm your schema deployed correctly:

query {

__schema { queryType { name } mutationType { name } } }

Then verify your specific type appeared:

query {

__type(name: "LoyaltyBalanceOutput") { fields { name type { name kind } } } }

If your type is missing, the schema cache is stale or the module is disabled. Check with:

bin/magento module:status Unomage_LoyaltyPoints

bin/magento cache:flush

Resolver errors in developer mode surface as full stack traces in the GraphQL response body under the errors key. The path value identifies exactly which field triggered the failure.

Log slow resolvers with a plugin on ResolverInterface::resolve. Wrap the resolve call with microtime() before and after. This surfaces N+1 problems quickly:

<?php

// Plugin/ResolverTimingPlugin.php declare(strict_types=1);

namespace Unomage\LoyaltyPoints\Plugin;

use Magento\Framework\GraphQl\Config\Element\Field; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Psr\Log\LoggerInterface;

class ResolverTimingPlugin { public function __construct(private readonly LoggerInterface $logger) {}

public function aroundResolve( ResolverInterface $subject, callable $proceed, Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ): mixed { $start = microtime(true); $result = $proceed($field, $context, $info, $value, $args); $elapsed = microtime(true) - $start;

$this->logger->debug(sprintf( 'GraphQL resolver %s took %.4f seconds', $subject::class, $elapsed ));

return $result; } }

Wire it in di.xml with disabled="true" by default. Enable it only in dev or staging—never production:

<type name="Magento\Framework\GraphQl\Query\ResolverInterface">

<plugin name="unomage_loyalty_resolver_timing" type="Unomage\LoyaltyPoints\Plugin\ResolverTimingPlugin" disabled="true"/> </type>

Test mutations with curl to rule out client-side issues:

curl -X POST https://your-store.test/graphql \

-H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -d '{"query":"mutation { redeemLoyaltyPoints(input: { cart_id: \"abc123\", points_to_redeem: 100 }) { success message remaining_balance } }"}'

Pro tip: Use Altair's history panel to replay requests during debugging. Replaying the exact same request body eliminates the "did I change the input?" variable entirely.

---

Wrapping Up

Custom Magento 2 GraphQL queries and mutations follow a consistent pattern once you've seen it end to end. Declare the schema in schema.graphqls. Bind the resolver class via the @resolver directive. Enforce auth through the $context object. Validate input with typed GraphQL exceptions.

Three things trip developers up most often. First, cache invalidation—flush after every schema change. Second, exception types—use input, authorization, and entity exceptions correctly. Third, identity verification—never trust client-supplied IDs.

Get those three right and building your Magento 2 headless API becomes straightforward. From here, explore Magento's @cache directive for query-level caching, deferred resolvers for performance-critical fields, and TypeResolverInterface for GraphQL interfaces and unions across your Adobe Commerce schema.

Quality Score: 70/100



Jot us a note and we’ll get back to you as quickly as possible.
Copyright © 2021-2026 Unomage, LLC. All rights reserved.