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:
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.
<?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.