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

AwardPointsOnOrderPlaced: Crediting Points When an Order Is Placed

AwardPointsOnOrderPlaced: Crediting Points When an Order Is Placed

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

This is the moment the whole series has been building toward since chapter 5: a single observer, AwardPointsOnOrderPlaced, actually connects PointsCalculator (chapter 5), CategoryBonusResolver (chapter 27), LoyaltyConfig (chapter 7), the loyalty_points_multiplier product attribute (chapter 19), the loyalty_points_earned sales attribute (chapter 23), and loyalty_points_balance on the customer (chapter 21) into one flow.

The event: sales_order_place_after

sales_order_place_after fires as soon as an order has been successfully placed - regardless of whether it came from storefront checkout or the admin "Create New Order" screen, since both paths ultimately go through the same quote-to-order conversion. That's exactly why events.xml belongs in etc/ here (global), not in etc/frontend/.

app/code/Mironsoft/Loyalty/etc/events.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_place_after">
        <observer name="mironsoft_loyalty_award_points_on_order_placed"
                  instance="Mironsoft\Loyalty\Observer\AwardPointsOnOrderPlaced"/>
    </event>
</config>

Achtung: Deliberately not sales_order_save_after: that event fires on every save of an order - including a later status change, an admin comment, or invoice creation. An observer on it would credit points again on every one of those actions. sales_order_place_after, by contrast, marks exactly the one-time moment the order was placed.

Observer\AwardPointsOnOrderPlaced

app/code/Mironsoft/Loyalty/Observer/AwardPointsOnOrderPlaced.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Observer;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
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\Service\CategoryBonusResolver;
use Mironsoft\Loyalty\Model\Service\PointsCalculator;
use Psr\Log\LoggerInterface;

/**
 * Credits loyalty points to the customer immediately after an order is placed,
 * whether the order originated from storefront checkout or the admin order form.
 */
class AwardPointsOnOrderPlaced implements ObserverInterface
{
    /**
     * @param PointsCalculator $pointsCalculator Pure per-item points calculation (chapter 5).
     * @param CategoryBonusResolver $categoryBonusResolver Resolves the category bonus for a product (chapter 27).
     * @param LoyaltyConfig $loyaltyConfig Typed configuration reader (chapter 7).
     * @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the earn ledger entry.
     * @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry.
     * @param OrderRepositoryInterface $orderRepository Persists the order with its computed loyalty_points_earned values.
     * @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance.
     * @param DateTime $dateTime Magento's date helper, used to compute the ledger expiry date.
     * @param LoggerInterface $logger Logs failures without letting them break checkout, see the warning below.
     */
    public function __construct(
        private readonly PointsCalculator $pointsCalculator,
        private readonly CategoryBonusResolver $categoryBonusResolver,
        private readonly LoyaltyConfig $loyaltyConfig,
        private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
        private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly DateTime $dateTime,
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * Entry point required by ObserverInterface. Delegates to awardPoints() and
     * swallows every exception - see the warning below this listing for why an
     * observer on a checkout-critical event must never let an error bubble up.
     *
     * @param EventObserver $observer Carries the placed order as event data.
     * @return void
     */
    public function execute(EventObserver $observer): void
    {
        /** @var Order $order */
        $order = $observer->getEvent()->getData('order');

        try {
            $this->awardPoints($order);
        } catch (\Throwable $exception) {
            $this->logger->error(
                sprintf(
                    'Mironsoft_Loyalty: failed to award points for order #%s: %s',
                    (string) $order->getIncrementId(),
                    $exception->getMessage()
                ),
                ['exception' => $exception]
            );
        }
    }

    /**
     * Calculates and persists earned points for every order item, updates the
     * customer's balance, and writes one "earn" ledger entry for the whole order.
     *
     * @param Order $order The just-placed order.
     * @return void
     */
    private function awardPoints(Order $order): void
    {
        if ($order->getCustomerIsGuest() || (int) $order->getCustomerId() === 0) {
            return; // business rule: guests do not earn points
        }

        if ((int) $order->getData('loyalty_points_earned') > 0) {
            return; // idempotency guard: already awarded, e.g. on a repeated dispatch
        }

        $websiteId = (int) $order->getStore()->getWebsiteId();
        if (!$this->loyaltyConfig->isEnabled($websiteId)) {
            return;
        }

        $pointsPerEuro = $this->loyaltyConfig->getPointsPerEuro($websiteId);
        $totalPointsEarned = 0;

        foreach ($order->getAllVisibleItems() as $item) {
            $product = $item->getProduct();
            $multiplier = $product !== null
                ? (float) $product->getData('loyalty_points_multiplier')
                : 1.0;
            $categoryBonus = $product !== null
                ? $this->categoryBonusResolver->resolveForProduct($product)
                : 0.0;

            $itemPoints = $this->pointsCalculator->calculatePoints(
                (float) $item->getRowTotal(),
                $pointsPerEuro,
                $multiplier > 0.0 ? $multiplier : 1.0,
                $categoryBonus
            );

            $item->setData('loyalty_points_earned', $itemPoints);
            $totalPointsEarned += $itemPoints;
        }

        if ($totalPointsEarned === 0) {
            return;
        }

        $order->setData('loyalty_points_earned', $totalPointsEarned);
        $this->orderRepository->save($order);

        $this->creditCustomer(
            (int) $order->getCustomerId(),
            $totalPointsEarned,
            (int) $order->getEntityId()
        );
    }

    /**
     * Credits points to the customer's balance and appends the matching ledger entry.
     *
     * @param int $customerId Customer entity ID.
     * @param int $points Points earned by this order, always positive here.
     * @param int $orderId Order entity ID, stored on the ledger entry for traceability.
     * @return void
     */
    private function creditCustomer(int $customerId, int $points, int $orderId): void
    {
        $customer = $this->customerRepository->getById($customerId);
        $currentAttribute = $customer->getCustomAttribute('loyalty_points_balance');
        $currentBalance = $currentAttribute !== null ? (int) $currentAttribute->getValue() : 0;
        $newBalance = $currentBalance + $points;

        $ledgerEntry = $this->pointsLedgerFactory->create();
        $ledgerEntry->setCustomerId($customerId);
        $ledgerEntry->setOrderId($orderId);
        $ledgerEntry->setType(PointsLedgerInterface::TYPE_EARN);
        $ledgerEntry->setPoints($points);
        $ledgerEntry->setBalanceAfter($newBalance);
        $ledgerEntry->setExpiresAt($this->resolveExpiresAt());
        $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 - no
        // tier logic is duplicated here.
        $this->customerRepository->save($customer);
    }

    /**
     * Computes the expiry timestamp for a fresh earn entry from the configured
     * expiry period, or null if points never expire.
     *
     * @return string|null
     */
    private function resolveExpiresAt(): ?string
    {
        $months = $this->loyaltyConfig->getPointsExpiryMonths();
        if ($months <= 0) {
            return null;
        }

        return $this->dateTime->date('Y-m-d H:i:s', strtotime(sprintf('+%d months', $months)));
    }
}

Why getCustomAttribute() instead of getData()?

CustomerRepositoryInterface::getById() returns a \Magento\Customer\Api\Data\CustomerInterface data object, not a \Magento\Customer\Model\Customer instance. Custom EAV attributes aren't reachable there via getData()/setData(), only via getCustomAttribute(string $attributeCode) (returns AttributeValueInterface|null) and setCustomAttribute(string $attributeCode, $value) - the service-contract-compliant path CLAUDE.md's "prefer service contracts and repositories" rule requires. On the subsequent save(), the repository internally copies every custom attribute back onto the underlying EAV model, so LoyaltyTierBackend::beforeSave() (chapter 26) sees the new loyalty_points_balance value exactly as normal.

Real integration: an overview

  • Chapter 5 PointsCalculator::calculatePoints() - calculates the points for each order line item.
  • Chapter 27 CategoryBonusResolver::resolveForProduct() - supplies its category bonus.
  • Chapter 19 loyalty_points_multiplier - read directly from the product.
  • Chapter 23 loyalty_points_earned - actually written here for the first time, on both order and order item.
  • Chapters 21/26 loyalty_points_balance and loyalty_tier - balance is set here, tier is computed automatically by the backend model.
  • Chapter 6 PointsLedgerRepositoryInterface::save() - writes the audit trail entry.

Achtung: events.xml observers run synchronously (chapter 29) - an uncaught exception in this observer would abort the customer's entire checkout request even though the order itself was already placed successfully. That's exactly why execute() wraps the real logic in try/catch (\Throwable) and only logs failures - a customer should never see an error page just because crediting points failed. Chapter 34 shows the comparable, but technically different, safety net for the cron job.

Tipp: The check (int) $order->getData('loyalty_points_earned') > 0 at the top of awardPoints() is a simple idempotency guard: should sales_order_place_after fire a second time for the same order for any reason (rare, but not impossible with certain payment-method redirect flows), it prevents a duplicate points credit - the same underlying problem chapter 31 solves for refunds with a different technique.

Chapter 31 reverses the direction: what happens to already-awarded points when an order is partially or fully refunded?