Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

A GraphQL Mutation: Redeeming a Reward via GraphQL

A GraphQL Mutation: Redeeming a Reward via GraphQL

~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Chapter 81 deliberately extracted the redemption logic into Model\RewardRedemptionManagement instead of hiding it inside the webapi call - that decision pays off right now. This chapter adds the redeemLoyaltyReward GraphQL mutation without the resolver performing a single points or balance calculation of its own.

The input/output pattern

As the separate GraphQL series already shows on its own examples, Magento's own mutations almost always follow the same pattern: an input type instead of loose arguments, a dedicated Output type as the return value. Adopted here 1:1:

app/code/Mironsoft/Loyalty/etc/schema.graphqls (extended)
type Mutation {
    redeemLoyaltyReward(
        input: RedeemLoyaltyRewardInput!
    ): RedeemLoyaltyRewardOutput
        @resolver(class: "Mironsoft\\Loyalty\\Model\\Resolver\\RedeemLoyaltyReward")
        @doc(description: "Redeems a loyalty reward for the current customer")
}

input RedeemLoyaltyRewardInput @doc(description: "Input for redeemLoyaltyReward") {
    reward_id: Int!
}

type RedeemLoyaltyRewardOutput @doc(description: "Result of redeemLoyaltyReward") {
    reward_id: Int!
    points_spent: Int!
    points_balance_after: Int!
    redeemed_at: String!
}

The resolver: a pure translation layer

The resolver injects Api\RewardRedemptionManagementInterface - the exact same class webapi.xml calls in chapter 81 under POST /V1/loyalty/rewards/:rewardId/redeem. No second implementation, no copy-pasting the four-step logic - REST and GraphQL are genuinely just two different transport layers over the same service contract here, exactly as chapter 79 set out as the goal.

app/code/Mironsoft/Loyalty/Model/Resolver/RedeemLoyaltyReward.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Resolver;

use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
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 Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface;

/**
 * Resolves the redeemLoyaltyReward mutation field. Deliberately does not
 * reimplement any redemption logic - it injects the exact same
 * RewardRedemptionManagementInterface the REST endpoint in chapter 81 uses,
 * and only translates GraphQL input/context into that service contract's
 * call and its exceptions into GraphQL-flavored ones.
 */
class RedeemLoyaltyReward implements ResolverInterface
{
    /**
     * @param RewardRedemptionManagementInterface $redemptionManagement Shared redemption business logic (chapter 81).
     */
    public function __construct(
        private readonly RewardRedemptionManagementInterface $redemptionManagement,
    ) {
    }

    /**
     * @param Field $field Resolved GraphQL field configuration.
     * @param mixed $context Resolver context, carries the customer ID.
     * @param ResolveInfo $info GraphQL resolve tree info.
     * @param array|null $value Parent resolver's value, unused for a top-level field.
     * @param array|null $args Arguments passed to the redeemLoyaltyReward field (input.reward_id).
     * @return array<string, mixed>
     * @throws GraphQlAuthorizationException
     * @throws GraphQlInputException
     * @throws GraphQlNoSuchEntityException
     */
    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        if (!$context->getExtensionAttributes()->getIsCustomer()) {
            throw new GraphQlAuthorizationException(
                __('The current customer isn\'t authorized.')
            );
        }

        $rewardId = (int) ($args['input']['reward_id'] ?? 0);
        $customerId = (int) $context->getUserId();

        try {
            $result = $this->redemptionManagement->redeem($rewardId, $customerId);
        } catch (NoSuchEntityException $exception) {
            throw new GraphQlNoSuchEntityException(__($exception->getMessage()), $exception);
        } catch (LocalizedException $exception) {
            // Covers the "not enough points" / "reward inactive" business rules from
            // RewardRedemptionManagement::redeem() - the same exception class chapter 81's
            // REST endpoint lets bubble up unchanged, remapped here to the GraphQL family.
            throw new GraphQlInputException(__($exception->getMessage()), $exception);
        }

        return [
            'reward_id' => $result->getRewardId(),
            'points_spent' => $result->getPointsSpent(),
            'points_balance_after' => $result->getPointsBalanceAfter(),
            'redeemed_at' => $result->getRedeemedAt(),
        ];
    }
}

Exception mapping: LocalizedException becomes GraphQlInputException

RewardRedemptionManagement::redeem() throws NoSuchEntityException (unknown reward) and LocalizedException (not enough points, inactive reward) - the same exception classes the REST route also lets pass through unchanged, where Magento's webapi framework automatically translates them into a matching HTTP status code. GraphQL doesn't perform that translation automatically: the resolver deliberately catches both types and rethrows their GraphQL counterparts - GraphQlNoSuchEntityException and GraphQlInputException respectively - carrying the same message, instead of a generic "Internal server error" response.

Achtung: Letting a LocalizedException fall through a GraphQL resolver untranslated doesn't reach the client as a helpful error message - it lands as a blanket "Internal server error" with HTTP 500, since Magento's GraphQL error handling only surfaces exception messages for the dedicated GraphQlInputException/GraphQlAuthorizationException/GraphQlNoSuchEntityException family by default. The try/catch block here isn't a style choice - it's what actually gets "Not enough points to redeem this reward." in front of the customer at all.

Testing the mutation

mutation RedeemReward($rewardId: Int!) {
  redeemLoyaltyReward(input: { reward_id: $rewardId }) {
    points_spent
    points_balance_after
    redeemed_at
  }
}

Tipp: Like every mutation, redeemLoyaltyReward also runs sequentially rather than in parallel with other fields in the same request - relevant once a client bundles several mutations into a single GraphQL request and relies on a fixed order (say: redeem first, then reload the new balance via loyaltyPointsSummary as a second query in the same request - though points_balance_after from the mutation's own response already covers that need anyway).

With REST and GraphQL both built on the same business logic, chapter 84 shifts perspective: no more API client, but the storefront's own Hyvä frontend, showing the points balance in the mini-cart via customer section data.