Writing Custom GraphQL Mutations in Magento 2
AI generated
M2
di.xml
Magento 2 · GraphQL · API Development
Writing Custom GraphQL Mutations in Magento 2
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.

18 min read schema.graphqls · ResolverInterface · Validation · Transactions Magento 2.4.x · GraphQL

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.

11. FAQ: Custom GraphQL Mutations in Magento 2

1Where is a custom mutation declared?
In the module's schema.graphqls file, with the @resolver directive connecting it to the PHP class.
2Which interface does the resolver need?
ResolverInterface with resolve(). The return value must match the schema's output type.
3How are errors returned?
Through GraphQlInputException and related classes, instead of generic exceptions that arrive as an undifferentiated internal server error.
4Does business logic belong in the resolver?
Better moved into a separate service. The resolver only checks permissions and maps results.
5How do you prevent double execution?
An existence check before the insert, or an idempotency key with a unique database index.
6Why invalidate the cache?
Without invalidation, queries serve stale data from the full page cache.
7How do you test a mutation?
With GraphQlAbstract against a real test database, covering success and error cases.
8When is a plugin on a native mutation enough?
Only for very small, closely related additions. For self contained business logic, prefer a custom mutation.
9Does every resolver need a di.xml entry?
Only when dependencies are injected through an interface. Otherwise the @resolver directive in the schema is enough.
10Which scalar types for the input type?
Explicit types like Int, String and Boolean instead of generic JSON strings, for full GraphQL validation.