from schema.graphqls to a production ready resolver class
A custom GraphQL mutation in Magento 2 is more than a new endpoint: it ties schema declaration, resolver logic, validation, transaction safety and cache invalidation together into one consistent whole. Anyone who separates these building blocks cleanly ends up with a mutation that feels like a native Magento feature and runs reliably in production storefronts.
Table of Contents
- 1. Why custom GraphQL mutations are needed
- 2. Declaring the mutation in schema.graphqls
- 3. Implementing the resolver class
- 4. Input validation and GraphQL exceptions
- 5. Module setup and DI configuration
- 6. Transaction safety and data consistency
- 7. Cache invalidation after the mutation
- 8. Securing custom mutations with integration tests
- 9. Native vs. custom mutations compared
- 10. Summary
- 11. FAQ
1. Why custom GraphQL mutations are needed
Magento already ships with a large number of native GraphQL mutations, from addProductsToCart to createCustomer. As soon as a project needs to represent a business process that goes beyond the standard checkout, such as a loyalty points system, a custom approval workflow in a B2B context, or an external payment confirmation, the native schema falls short. This is exactly where a custom GraphQL mutation comes in: it extends the schema with a domain-appropriate endpoint instead of squeezing foreign functionality into existing mutations.
The central advantage of a custom GraphQL mutation over a separate REST endpoint lies in the consistency of the storefront API. Frontend teams already work with a GraphQL client, caching headers and query batching are already established, and the mutation fits seamlessly into existing error handling and typing. Anyone who instead builds an additional REST controller has to maintain authentication, rate limiting and response formats twice. A custom GraphQL mutation avoids this redundancy and stays within the same API contract as the rest of the storefront.
Before starting the implementation, it is worth taking a short inventory: which data does the mutation change, which permissions are required, and does the change need to be processed synchronously or asynchronously. These questions determine how much effort goes into validation, transaction logic and error handling before the first line of resolver code is written.
2. Declaring the mutation in schema.graphqls
Every GraphQL mutation in Magento starts with a declaration in a schema.graphqls file inside the custom module. This file extends the existing Mutation type with a new field, defines input parameters through an input type, and sets the return type through an output type. The important part is the @resolver directive: it connects the schema field to the PHP class that will later execute the actual logic. Without this directive, Magento does not know which code runs when the mutation is called.
A common beginner mistake is using primitive scalar types such as String for structured data. For a custom GraphQL mutation that, for example, creates a review, the input type should have explicit fields for rating, title and text with proper scalar types (Int, String), instead of bundling everything into a single JSON string. This makes full use of built in GraphQL validation and makes introspection tools such as GraphQL Playground genuinely useful.
# app/code/Mironsoft/CustomerReview/etc/schema.graphqls
# Custom GraphQL mutation for submitting a product review
type Mutation {
createProductReview(input: CreateProductReviewInput!): CreateProductReviewOutput
@resolver(class: "Mironsoft\\CustomerReview\\Model\\Resolver\\CreateProductReview")
@doc(description: "Create a new review for a product")
}
input CreateProductReviewInput {
sku: String! @doc(description: "SKU of the reviewed product")
rating: Int! @doc(description: "Rating between 1 and 5")
title: String! @doc(description: "Short review headline")
text: String! @doc(description: "Full review text")
}
type CreateProductReviewOutput {
review_id: Int! @doc(description: "ID of the created review")
status: String! @doc(description: "pending or approved")
}
3. Implementing the resolver class
The actual logic of every custom GraphQL mutation lives in a class that implements Magento\Framework\GraphQl\Query\ResolverInterface. The central method resolve() receives four parameters: the field object, the context (including customer data and store), query information, and an array with the arguments from the input type. The return value must exactly match the structure of the output type declared in the schema, otherwise Magento reports a schema validation error at runtime.
A well structured resolver class delegates the actual business logic to a service or a repository instead of implementing it directly inside the resolver. The resolver itself only handles three tasks: extracting arguments from the GraphQL request, checking permissions through the ContextInterface, and mapping the result of the service call into the array structure required by the schema. This separation makes the mutation testable, because the service can be verified independently from the GraphQL layer.
<?php
declare(strict_types=1);
namespace Mironsoft\CustomerReview\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\Resolver\ContextInterface;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\CustomerReview\Api\ReviewCreatorInterface;
/**
* Resolver for the createProductReview custom GraphQL mutation.
*/
final class CreateProductReview implements ResolverInterface
{
/**
* @param ReviewCreatorInterface $reviewCreator Service that encapsulates the review creation logic
*/
public function __construct(
private readonly ReviewCreatorInterface $reviewCreator
) {
}
/**
* Resolve the createProductReview mutation.
*
* @param Field $field
* @param ContextInterface $context
* @param ResolveInfo $info
* @param array|null $value
* @param array|null $args
* @return array<string, mixed>
* @throws GraphQlAuthorizationException
* @throws GraphQlInputException
*/
public function resolve(
Field $field,
$context,
ResolveInfo $info,
array $value = null,
array $args = null
): array {
if (false === $context->getExtensionAttributes()->getIsCustomer()) {
throw new GraphQlAuthorizationException(__('Only logged-in customers can submit reviews.'));
}
$input = $args['input'] ?? [];
if (empty($input['sku']) || empty($input['rating'])) {
throw new GraphQlInputException(__('SKU and rating are required.'));
}
$review = $this->reviewCreator->create(
(string) $input['sku'],
(int) $context->getUserId(),
(int) $input['rating'],
(string) $input['title'],
(string) $input['text']
);
return [
'review_id' => $review->getId(),
'status' => $review->getStatus(),
];
}
}
4. Input validation and GraphQL exceptions
Validation in a custom GraphQL mutation happens on two levels. The first level is type validation through the schema itself: a required field without ! lets Magento reject the request before the resolver is even called. The second level is business validation inside the resolver or the underlying service, for example whether a review rating falls between 1 and 5 or whether the referenced product actually exists. Magento provides special exception classes for this level that produce GraphQL compliant error responses.
GraphQlInputException signals invalid client input and is returned as an error with code GRAPHQL_INPUT_ERROR in the response, without aborting the whole request. GraphQlAuthorizationException communicates missing permissions, and GraphQlNoSuchEntityException is used when a referenced entity does not exist. Important for every mutation: generic \Exception objects should never be thrown directly from the resolver, because they arrive at the client as an undifferentiated Internal server error and give no indication of the actual cause.
5. Module setup and DI configuration
For a custom GraphQL mutation to be loaded at all, the module needs a complete registration: registration.php, a module.xml with a dependency on Magento_GraphQl, and the schema.graphqls file in the etc directory. The resolver class itself usually does not need its own di.xml entry, because it is referenced through the @resolver directive in the schema and instantiated automatically by the object manager. Things look different when the resolver class has dependencies that are injected through an interface: here, di.xml defines the concrete implementation for that interface.
A module with several mutations benefits from a clear naming convention, such as a Resolver namespace with one class per mutation, and a separate Service namespace for the actual business logic. This structure prevents GraphQL specific code, such as exception handling and context checks, from mixing with business logic that could also be reused by a REST endpoint or a cron job.
<!-- app/code/Mironsoft/CustomerReview/etc/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">
<preference for="Mironsoft\CustomerReview\Api\ReviewCreatorInterface"
type="Mironsoft\CustomerReview\Model\ReviewCreator" />
</config>
6. Transaction safety and data consistency
As soon as a GraphQL mutation performs more than one database operation, for example creating a review and simultaneously updating a product average rating, that operation needs to run as a transaction. Without an explicit transaction, an error between the two write operations can leave inconsistent data behind: the review exists, but the average rating was never updated. The service behind the resolver class should therefore use the resource connection layer to bundle both operations into a single transaction.
A second important point concerns idempotency: GraphQL clients occasionally retry requests automatically on network errors. A mutation that creates a duplicate entry on the second call with identical parameters produces duplicate reviews or duplicate orders in the frontend. A defensive implementation therefore checks whether a matching record already exists before the insert, or uses a client generated idempotency key that is mapped to a unique database index.
7. Cache invalidation after the mutation
Magento's GraphQL layer uses the same full page cache mechanism as the classic storefront for queries, including cache tags. A custom GraphQL mutation that changes product data, for example a product's average rating value, therefore needs to invalidate the matching cache tags, otherwise subsequent queries serve stale data from the cache. The resolver or the underlying service calls the CacheInterface or the corresponding indexer trigger for this, depending on whether synchronous or asynchronous invalidation is desired.
For product related mutations, the tag is usually cat_p_{entity_id}, for category related changes cat_c_{entity_id}. Anyone who forgets the invalidation ends up with a subtle bug pattern: the mutation reports success, but the frontend shows the old state for a few minutes until the full page cache expires normally. Such bugs are often invisible in a development environment with a disabled cache and only show up in production.
8. Securing custom mutations with integration tests
Magento ships with Magento\TestFramework\TestCase\GraphQlAbstract, a base class that executes GraphQL requests against a real test database, including authentication headers for logged in customers. An integration test for a custom GraphQL mutation should cover at least three cases: the success case with valid data, the error case with a missing required field, and the authorization case without a valid customer token.
These tests run considerably faster than end to end tests through a real browser and catch regressions before they reach production, for example when a schema field is accidentally renamed or an exception class is replaced by a generic one. For teams maintaining several mutations per module, it is worth building a shared test fixture that provides test customers and test products through @magentoDataFixture, instead of letting every test create the data individually.
<?php
declare(strict_types=1);
namespace Mironsoft\CustomerReview\Test\Integration;
use Magento\TestFramework\TestCase\GraphQlAbstract;
/**
* Integration test for the createProductReview custom GraphQL mutation.
*/
class CreateProductReviewTest extends GraphQlAbstract
{
/**
* @magentoApiDataFixture Magento/Catalog/_files/product_simple.php
*/
public function testCreateReviewSucceeds(): void
{
$query = <<<MUTATION
mutation {
createProductReview(input: {
sku: "simple",
rating: 5,
title: "Great product",
text: "Works exactly as described."
}) {
review_id
status
}
}
MUTATION;
$response = $this->graphQlMutation($query, [], '', $this->getCustomerHeaders());
self::assertArrayHasKey('review_id', $response['createProductReview']);
self::assertSame('pending', $response['createProductReview']['status']);
}
}
9. Native vs. custom mutations compared
Before writing a custom GraphQL mutation, it is worth looking at alternatives: sometimes the goal can be reached by extending a native mutation through a plugin, sometimes a completely new endpoint is the cleaner solution.
| Criterion | Extend native mutation | Custom mutation |
|---|---|---|
| Domain fit | Only when the domain truly matches the existing mutation | Any new business logic can be represented |
| Upgrade safety | Plugin can break on core changes | Own schema, independent from core |
| Implementation effort | Low, only a plugin class needed | Schema, resolver, service, tests |
| API clarity for the frontend | Extra fields feel bolted on | Own, clearly named endpoint |
| Testability | Must also cover the existing test suite | Isolated integration tests |
In practice, the custom GraphQL mutation almost always wins once the business logic is self contained. Only for very small additions, for example an extra optional field in createCustomer, is a plugin extension of the native mutation the faster and more maintainable path.
Mironsoft
Magento 2 GraphQL development and API architecture
Custom GraphQL mutations for your Magento shop?
We design and implement custom GraphQL mutations, including schema design, validation, transaction logic and test coverage, so your storefront API stays consistent and maintainable.
Schema design
Clean input and output types for sustainable GraphQL APIs
Resolver development
Testable resolvers with a clear separation of GraphQL and business logic
Quality assurance
Integration tests and cache invalidation considered from the start
10. Summary
A custom GraphQL mutation in Magento 2 emerges from five connected building blocks: the schema declaration in schema.graphqls, a resolver class with a clear separation of the GraphQL layer and business logic, consistent validation through GraphQlInputException and related classes, transaction safety for multi step data changes, and correct cache invalidation. Anyone who skips one of these building blocks risks unclear error responses, inconsistent data, or stale cache states in the frontend.
The effort for a fully thought through mutation is higher than for a quick prototype, but it pays off with every extension: new fields in the input type, additional validation rules, or another cache tag can be added within the established structure without a rewrite. Integration tests make these extensions safe, because regressions surface immediately instead of being discovered only in live operation.
Custom GraphQL Mutations in Magento 2 — Key Takeaways
Schema
A mutation field with the @resolver directive, explicit input and output types instead of generic JSON strings.
Resolver
ResolverInterface::resolve() delegates to a service, checks permissions, and returns the exact output structure.
Validation & Transactions
GraphQlInputException for client errors, database transactions for multi step changes, idempotency considered.
Cache & Tests
Invalidate cache tags after data changes, integration tests with GraphQlAbstract for success and error cases.