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

System/Config/Setting: A Custom Configuration Page for the Loyalty Program

System/Config/Setting: A Custom Configuration Page for the Loyalty Program

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

Four values from the specification - enabled or not, points per euro, expiry time in months, tier thresholds - need an interface a shop operator can adjust without touching code. Magento's System/Config framework declares all of this via XML: system.xml for the form structure, config.xml for default values, acl.xml for permissions.

acl.xml: permissions for menu and configuration

Two ACL resources are needed: Mironsoft_Loyalty::loyalty as a parent resource that later blocks hang their own admin permissions off of (for example the reward grid from chapter 16), and Mironsoft_Loyalty::config_section specifically for this configuration page under Stores > Configuration.

app/code/Mironsoft/Loyalty/etc/acl.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
    <acl>
        <resources>
            <resource id="Magento_Backend::admin">
                <resource id="Mironsoft_Loyalty::loyalty" title="Loyalty &amp; Rewards" sortOrder="50"/>
            </resource>
            <resource id="Magento_Backend::stores">
                <resource id="Magento_Backend::stores_settings">
                    <resource id="Magento_Config::config">
                        <resource id="Mironsoft_Loyalty::config_section"
                                  title="Loyalty &amp; Rewards Configuration"/>
                    </resource>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

etc/adminhtml/system.xml

Every field gets showInDefault/showInWebsite/showInStore matching the scope defined in the specification: enabled and points_per_euro can be set at the website level, while points_expiry_months and tier_thresholds are deliberately default-scope only - a different expiry per website would needlessly complicate the ledger evaluation in chapter 33.

app/code/Mironsoft/Loyalty/etc/adminhtml/system.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <tab id="mironsoft" translate="label" sortOrder="300">
            <label>Mironsoft</label>
        </tab>
        <section id="mironsoft_loyalty" translate="label" sortOrder="200"
                 showInDefault="1" showInWebsite="1" showInStore="0">
            <tab>mironsoft</tab>
            <label>Loyalty &amp; Rewards</label>
            <resource>Mironsoft_Loyalty::config_section</resource>
            <group id="general" translate="label" sortOrder="10"
                   showInDefault="1" showInWebsite="1" showInStore="0">
                <label>General Settings</label>
                <field id="enabled" translate="label" type="select" sortOrder="10"
                       showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Enable Loyalty Program</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="points_per_euro" translate="label" type="text" sortOrder="20"
                       showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Points per Euro Spent</label>
                    <validate>validate-number validate-greater-than-zero</validate>
                    <depends>
                        <field id="enabled">1</field>
                    </depends>
                </field>
                <field id="points_expiry_months" translate="label" type="text" sortOrder="30"
                       showInDefault="1" showInWebsite="0" showInStore="0">
                    <label>Points Expiry (Months)</label>
                    <validate>validate-digits validate-greater-than-zero</validate>
                </field>
                <field id="tier_thresholds" translate="label,comment" type="textarea" sortOrder="40"
                       showInDefault="1" showInWebsite="0" showInStore="0">
                    <label>Tier Thresholds (JSON)</label>
                    <comment>JSON object with the point totals required to reach the
                        "silver" and "gold" tier, e.g. {"silver":500,"gold":2000}.</comment>
                </field>
            </group>
        </section>
    </system>
</config>

config.xml: default values

app/code/Mironsoft/Loyalty/etc/config.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <mironsoft_loyalty>
            <general>
                <enabled>0</enabled>
                <points_per_euro>1.0000</points_per_euro>
                <points_expiry_months>12</points_expiry_months>
                <tier_thresholds>{"silver":500,"gold":2000}</tier_thresholds>
            </general>
        </mironsoft_loyalty>
    </default>
</config>

Achtung: config.xml sets a safe default: the program is enabled = 0 until someone actively turns it on. A module that enables itself by default surprises shop operators with unexpected behavior right after installation - a common, avoidable mistake in custom modules.

A typed config reader instead of raw ScopeConfigInterface calls

Scattering ScopeConfigInterface::getValue() directly across observers, controllers, or the console command means repeating the config path as a string in multiple places - a typo then only surfaces at runtime. LoyaltyConfig encapsulates all four paths in a single place.

app/code/Mironsoft/Loyalty/Model/Config/LoyaltyConfig.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Config;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;

/**
 * Typed reader for the mironsoft_loyalty/* configuration values.
 */
class LoyaltyConfig
{
    private const XML_PATH_ENABLED = 'mironsoft_loyalty/general/enabled';
    private const XML_PATH_POINTS_PER_EURO = 'mironsoft_loyalty/general/points_per_euro';
    private const XML_PATH_POINTS_EXPIRY_MONTHS = 'mironsoft_loyalty/general/points_expiry_months';
    private const XML_PATH_TIER_THRESHOLDS = 'mironsoft_loyalty/general/tier_thresholds';

    /**
     * @param ScopeConfigInterface $scopeConfig Reads store configuration values.
     */
    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
    ) {
    }

    /**
     * @param int|null $websiteId Website scope, null falls back to the default scope.
     * @return bool
     */
    public function isEnabled(?int $websiteId = null): bool
    {
        return $this->scopeConfig->isSetFlag(
            self::XML_PATH_ENABLED,
            ScopeInterface::SCOPE_WEBSITE,
            $websiteId
        );
    }

    /**
     * @param int|null $websiteId Website scope, null falls back to the default scope.
     * @return float
     */
    public function getPointsPerEuro(?int $websiteId = null): float
    {
        return (float) $this->scopeConfig->getValue(
            self::XML_PATH_POINTS_PER_EURO,
            ScopeInterface::SCOPE_WEBSITE,
            $websiteId
        );
    }

    /**
     * @return int
     */
    public function getPointsExpiryMonths(): int
    {
        return (int) $this->scopeConfig->getValue(self::XML_PATH_POINTS_EXPIRY_MONTHS);
    }

    /**
     * @return string
     */
    public function getTierThresholdsJson(): string
    {
        return (string) $this->scopeConfig->getValue(self::XML_PATH_TIER_THRESHOLDS);
    }
}

Tipp: getTierThresholdsJson() returns the JSON unchanged as a string and leaves decoding to PointsCalculator::determineTier() (chapter 5) - LoyaltyConfig deliberately knows no business logic, only configuration values. This separation keeps both classes independently testable.

With configuration and ACL in place, chapter 8 covers the last infrastructure building block of block 1: a custom cache type.