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