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

GraphQL-Mutation: Prämie per GraphQL einlösen

GraphQL-Mutation: Prämie per GraphQL einlösen

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

Kapitel 81 hat die Einlöse-Logik bewusst in Model\RewardRedemptionManagement ausgelagert, statt sie im Webapi-Aufruf zu verstecken - genau dafür zahlt sich das jetzt aus. Dieses Kapitel fügt die GraphQL-Mutation redeemLoyaltyReward hinzu, ohne dass der Resolver auch nur eine einzige Punkte- oder Guthaben-Berechnung selbst durchführt.

Das Input/Output-Muster

Wie schon die separate GraphQL-Serie an eigenen Beispielen zeigt, folgen Magentos eigene Mutations fast durchgängig demselben Muster: ein input-Typ statt loser Argumente, ein eigener Output-Typ als Rückgabe. Übernommen hier 1:1:

app/code/Mironsoft/Loyalty/etc/schema.graphqls (ergänzt)
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!
}

Der Resolver: eine reine Übersetzungsschicht

Der Resolver injiziert Api\RewardRedemptionManagementInterface - exakt dieselbe Klasse, die webapi.xml in Kapitel 81 unter POST /V1/loyalty/rewards/:rewardId/redeem aufruft. Keine zweite Implementierung, kein Copy-Paste der Vier-Schritte-Logik - REST und GraphQL sind hier tatsächlich nur zwei unterschiedliche Transportschichten über demselben Service Contract, exakt wie Kapitel 79 es als Ziel formuliert hat.

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 wird zu GraphQlInputException

RewardRedemptionManagement::redeem() wirft NoSuchEntityException (unbekannte Prämie) und LocalizedException (zu wenig Punkte, inaktive Prämie) - dieselben Exception-Klassen, die auch die REST-Route unverändert durchreicht, wo Magentos Webapi-Framework sie automatisch in einen passenden HTTP-Statuscode übersetzt. GraphQL kennt diese Übersetzung nicht automatisch: Der Resolver fängt beide Typen gezielt ab und wirft die GraphQL-eigenen Pendants - GraphQlNoSuchEntityException bzw. GraphQlInputException - mit derselben Nachricht weiter, statt einer generischen "Internal server error"-Antwort.

Achtung: Ein LocalizedException unübersetzt durch einen GraphQL-Resolver durchfallen zu lassen, landet nicht als hilfreiche Fehlermeldung beim Client, sondern als pauschales "Internal server error" mit HTTP 500 - Magentos GraphQL-Fehlerbehandlung zeigt Exception-Nachrichten standardmäßig nur für die explizit dafür vorgesehenen GraphQlInputException/GraphQlAuthorizationException/GraphQlNoSuchEntityException-Familie. Der try/catch-Block hier ist deshalb keine Stilfrage, sondern notwendig, damit "Not enough points to redeem this reward." überhaupt beim Kunden ankommt.

Die Mutation testen

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

Tipp: Wie jede Mutation läuft auch redeemLoyaltyReward sequenziell statt parallel zu anderen Feldern derselben Anfrage - relevant, sobald ein Client mehrere Mutations in einer einzigen GraphQL-Anfrage bündelt und sich dabei auf eine feste Reihenfolge verlässt (etwa: erst einlösen, danach im selben Request den neuen Punktestand über loyaltyPointsSummary als zweite Query nachladen - dafür reicht dann ohnehin schon points_balance_after aus der Mutation-Antwort selbst).

Mit REST und GraphQL beide auf derselben Business-Logik fertig, wechselt Kapitel 84 die Perspektive: kein API-Client mehr, sondern das eigene Hyvä-Frontend, das den Punktestand im Mini-Cart per Customer Section Data anzeigt.