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

Redeem-Controller: eine Prämie einlösen (POST-Action, CSRF, Validierung)

Redeem-Controller: eine Prämie einlösen (POST-Action, CSRF, Validierung)

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

Alle bisherigen Controller in Block 6 waren reine GET-Leseaktionen. Dieses Kapitel baut die erste schreibende Aktion: Redeem\Index bucht eine Einlösung gegen PointsLedgerRepositoryInterface (Kapitel 6) und aktualisiert den Punktestand des Kunden - dieselbe getCustomAttribute()/setCustomAttribute()-Technik wie in AwardPointsOnOrderPlaced (Kapitel 30), nur mit umgekehrtem Vorzeichen.

CsrfAwareActionInterface statt manueller Formkey-Prüfung

Magento\Framework\App\CsrfAwareActionInterface ist der seit Magento 2.3 empfohlene Weg, eine POST-Action CSRF-fähig zu machen. Beide Methoden dürfen null zurückgeben, um Magentos eingebautes Standardverhalten (Formkey-Session-Abgleich) zu übernehmen - genau das tut dieser Controller, es gibt keinen Grund, das Rad neu zu erfinden:

app/code/Mironsoft/Loyalty/Controller/Redeem/Index.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Controller\Redeem;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Customer\Controller\AccountInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\ResourceModel\Reward\CollectionFactory as RewardCollectionFactory;

/**
 * Redeems one reward for the logged-in customer: validates balance and reward
 * state, appends an append-only TYPE_REDEEM ledger entry (chapter 6), and debits
 * loyalty_points_balance on the customer (chapter 21). Implements AccountInterface
 * for the same reason History\Index does (chapter 45) - redemption always requires
 * a login, unlike browsing the catalog (chapter 49).
 */
class Index extends Action implements HttpPostActionInterface, CsrfAwareActionInterface, AccountInterface
{
    /**
     * @param Context $context Framework action context (redirect/message manager access).
     * @param CustomerSession $customerSession Provides the logged-in customer ID.
     * @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance.
     * @param RewardCollectionFactory $rewardCollectionFactory Loads the reward being redeemed.
     * @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the redemption ledger entry (chapter 6).
     * @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry.
     * @param LoyaltyConfig $loyaltyConfig Confirms the loyalty program is enabled before redeeming.
     */
    public function __construct(
        Context $context,
        private readonly CustomerSession $customerSession,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly RewardCollectionFactory $rewardCollectionFactory,
        private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
        private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
        private readonly LoyaltyConfig $loyaltyConfig,
    ) {
        parent::__construct($context);
    }

    /**
     * Returning null defers to Magento's default CSRF exception (a 400 response).
     *
     * @param RequestInterface $request Current HTTP request.
     * @return InvalidRequestException|null
     */
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    /**
     * Returning null defers to Magento's default form-key session validation - no
     * custom CSRF logic needed for a standard, session-authenticated POST form.
     *
     * @param RequestInterface $request Current HTTP request.
     * @return bool|null
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return null;
    }

    /**
     * @return Redirect
     */
    public function execute(): Redirect
    {
        /** @var Redirect $redirect */
        $redirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
        $rewardId = (int) $this->getRequest()->getParam('reward_id');

        if (!$this->loyaltyConfig->isEnabled() || $rewardId <= 0) {
            $this->messageManager->addErrorMessage(__('This reward cannot be redeemed right now.'));

            return $redirect->setPath('mironsoft_loyalty/catalog/index');
        }

        $reward = $this->rewardCollectionFactory->create()
            ->addActiveFilter()
            ->addFieldToFilter('entity_id', ['eq' => $rewardId])
            ->getFirstItem();

        if (!$reward->getId()) {
            $this->messageManager->addErrorMessage(__('This reward no longer exists.'));

            return $redirect->setPath('mironsoft_loyalty/catalog/index');
        }

        try {
            $this->redeem((int) $this->customerSession->getCustomerId(), (int) $reward->getPointsCost(), $rewardId);
            $this->messageManager->addSuccessMessage(
                __('You redeemed "%1" for %2 points.', $reward->getTitle(), $reward->getPointsCost())
            );
        } catch (LocalizedException $exception) {
            $this->messageManager->addErrorMessage($exception->getMessage());

            return $redirect->setPath('mironsoft_loyalty/catalog/view', ['reward_identifier' => $reward->getIdentifier()]);
        }

        return $redirect->setPath('mironsoft_loyalty/history/index');
    }

    /**
     * Validates the balance and books the redemption as an append-only ledger entry
     * plus a customer balance debit.
     *
     * @param int $customerId Customer entity ID.
     * @param int $pointsCost Points required for the reward being redeemed.
     * @param int $rewardId Reward entity ID, for the confirmation message only - the
     *   ledger schema (chapter 3) has no reward_id column, only order_id.
     * @return void
     * @throws LocalizedException If the balance is insufficient or the customer cannot be loaded.
     */
    private function redeem(int $customerId, int $pointsCost, int $rewardId): void
    {
        try {
            $customer = $this->customerRepository->getById($customerId);
        } catch (NoSuchEntityException $exception) {
            throw new LocalizedException(__('Your account could not be loaded.'), $exception);
        }

        $attribute = $customer->getCustomAttribute('loyalty_points_balance');
        $currentBalance = $attribute !== null ? (int) $attribute->getValue() : 0;

        if ($currentBalance < $pointsCost) {
            throw new LocalizedException(__('You do not have enough points for this reward.'));
        }

        $newBalance = $currentBalance - $pointsCost;

        /** @var PointsLedgerInterface $ledgerEntry */
        $ledgerEntry = $this->pointsLedgerFactory->create();
        $ledgerEntry->setCustomerId($customerId);
        $ledgerEntry->setOrderId(null);
        $ledgerEntry->setType(PointsLedgerInterface::TYPE_REDEEM);
        $ledgerEntry->setPoints(-$pointsCost);
        $ledgerEntry->setBalanceAfter($newBalance);
        $ledgerEntry->setExpiresAt(null);
        $this->pointsLedgerRepository->save($ledgerEntry);

        $customer->setCustomAttribute('loyalty_points_balance', $newBalance);
        // Saving through the repository re-triggers LoyaltyTierBackend::beforeSave()
        // (chapter 26), which recalculates loyalty_tier from the new balance.
        $this->customerRepository->save($customer);
    }
}

Warum die Route die technische URL bleibt

Das Formular auf der Detailseite postet bewusst an mironsoft_loyalty/redeem/index - die technische Route aus Kapitel 45, nicht an eine hübsche /treuepraemien/...-URL. Der Router aus Kapitel 46 fängt ohnehin nur GET-Navigationspfade ab; ein Formular-Ziel muss nicht SEO-freundlich sein, es wird nie verlinkt, nie indexiert und nie als Lesezeichen gespeichert.

<form method="post" action="<?= $escaper->escapeUrl($block->getUrl('mironsoft_loyalty/redeem/index')) ?>">
    <input type="hidden" name="form_key" value="<?= $escaper->escapeHtmlAttr($viewModel->getFormKey()) ?>">
    <input type="hidden" name="reward_id" value="<?= (int) $reward->getId() ?>">
    <button type="submit"><?= $escaper->escapeHtml(__('Redeem now')) ?></button>
</form>

Hyvä rendert keine Blocks per $block->getBlockHtml('formkey') (das wäre der Luma-Weg) - stattdessen liefert RewardDetail (Kapitel 49) einen zusätzlichen getFormKey(): string, der intern \Magento\Framework\Data\Form\FormKey::getFormKey() injiziert. Kapitel 53 zeigt das vollständige Template inklusive dieses Formulars.

// Ergänzung in Mironsoft\Loyalty\ViewModel\RewardDetail (Kapitel 49)
public function __construct(
    private readonly RequestInterface $request,
    private readonly CollectionFactory $rewardCollectionFactory,
    private readonly CustomerSession $customerSession,
    private readonly CustomerRepositoryInterface $customerRepository,
    private readonly \Magento\Framework\Data\Form\FormKey $formKey,
) {
}

public function getFormKey(): string
{
    return $this->formKey->getFormKey();
}

Achtung: Ledger-Einträge sind laut Kapitel 6 append-only - dieser Controller lädt nie einen bestehenden Eintrag, um ihn zu ändern, sondern erzeugt immer einen neuen über pointsLedgerFactory->create(). Ein zweiter, ernster Punkt: getById() gefolgt von save() ist hier nicht transaktional gegen parallele Requests desselben Kunden abgesichert - zwei gleichzeitige Einlösungen könnten theoretisch beide dieselbe (veraltete) $currentBalance lesen und den Kunden ins Minus schicken. Für ein Tutorial-Modul bewusst offen gelassen; eine Produktivversion würde diese Sektion mit einer Datenbank-Transaktion und pessimistischem Locking (SELECT ... FOR UPDATE auf die Customer-Zeile über ResourceConnection) absichern, statt sich auf Repository-Aufrufe allein zu verlassen.

Tipp: Kapitel 51 verdrahtet Catalog\Index, Catalog\View und History\Index jetzt vollständig per Layout-XML - Redeem\Index braucht bewusst keine eigene Layout-Datei, dazu mehr im nächsten Kapitel.