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

The Redeem Controller: Redeeming a Reward (POST Action, CSRF, Validation)

The Redeem Controller: Redeeming a Reward (POST Action, CSRF, Validation)

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

Every controller in block 6 so far has been a pure GET read action. This chapter builds the first write action: Redeem\Index books a redemption against PointsLedgerRepositoryInterface (chapter 6) and updates the customer's points balance - the same getCustomAttribute()/setCustomAttribute() technique as AwardPointsOnOrderPlaced (chapter 30), just with the sign flipped.

CsrfAwareActionInterface instead of a manual form-key check

Magento\Framework\App\CsrfAwareActionInterface has been the recommended way to make a POST action CSRF-aware since Magento 2.3. Both methods are allowed to return null to fall back to Magento's built-in default behavior (form-key session matching) - which is exactly what this controller does, there's no reason to reinvent it:

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);
    }
}

Why the route stays the technical URL

The form on the detail page deliberately posts to mironsoft_loyalty/redeem/index - the technical route from chapter 45, not a pretty /treuepraemien/... URL. The router from chapter 46 only ever intercepts GET navigation paths anyway; a form target doesn't need to be SEO-friendly, it's never linked to, never indexed, and never bookmarked.

<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ä doesn't render blocks via $block->getBlockHtml('formkey') (that would be the Luma way) - instead, RewardDetail (chapter 49) gets one extra getFormKey(): string method that internally injects \Magento\Framework\Data\Form\FormKey::getFormKey(). Chapter 53 shows the complete template including this form.

// Addition to Mironsoft\Loyalty\ViewModel\RewardDetail (chapter 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: Per chapter 6, ledger entries are append-only - this controller never loads an existing entry to change it, it always creates a new one via pointsLedgerFactory->create(). A second, more serious point: getById() followed by save() is not protected transactionally against concurrent requests from the same customer here - two simultaneous redemptions could theoretically both read the same (stale) $currentBalance and push the customer into the negative. Left deliberately open for a tutorial module; a production version would wrap this section in a database transaction with pessimistic locking (SELECT ... FOR UPDATE on the customer row via ResourceConnection) instead of relying on repository calls alone.

Tipp: Chapter 51 now wires Catalog\Index, Catalog\View, and History\Index fully via layout XML - Redeem\Index deliberately needs no layout file of its own, more on that in the next chapter.