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