Almost every module needs settings: an on/off switch, an API key, a threshold or a list of allowed options. Magento's Stores → Configuration screen is built from system.xml files, and values are stored per scope (default, website, store view) in core_config_data.

Step 1: Define the Section, Group and Fields

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="mageservices" translate="label" sortOrder="300">
            <label>MageServices</label>
        </tab>
        <section id="mageservices_erp" translate="label" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
            <label>ERP Integration</label>
            <tab>mageservices</tab>
            <resource>MageServices_Erp::config</resource>
            <group id="general" translate="label" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
                <label>General</label>
                <field id="enabled" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Enable Integration</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="api_url" translate="label comment" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>API URL</label>
                    <comment>Base URL of the ERP API, including https://</comment>
                    <validate>required-entry validate-url</validate>
                    <depends>
                        <field id="enabled">1</field>
                    </depends>
                </field>
                <field id="api_key" translate="label" type="obscure" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>API Key</label>
                    <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
                    <depends>
                        <field id="enabled">1</field>
                    </depends>
                </field>
                <field id="order_statuses" translate="label" type="multiselect" sortOrder="40" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Export Orders With Status</label>
                    <source_model>Magento\Sales\Model\Config\Source\Order\Status</source_model>
                    <can_be_empty>1</can_be_empty>
                </field>
            </group>
        </section>
    </system>
</config>

The Encrypted backend model encrypts the value with the key in app/etc/env.php before saving it, and the obscure type hides it in the form. Never store API secrets in plain text.

Step 2: Protect the Section With ACL

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="Magento_Backend::stores">
                    <resource id="Magento_Backend::stores_settings">
                        <resource id="Magento_Config::config">
                            <resource id="MageServices_Erp::config" title="MageServices ERP Integration"/>
                        </resource>
                    </resource>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

Step 3: Defaults in config.xml

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>
        <mageservices_erp>
            <general>
                <enabled>0</enabled>
                <order_statuses>processing</order_statuses>
            </general>
        </mageservices_erp>
    </default>
</config>

Step 4: Read Values Through a Typed Config Class

Wrap configuration access in one class instead of calling ScopeConfigInterface with string paths all over your module. It is easier to test and to change.

Model/Config.php
<?php
declare(strict_types=1);

namespace MageServices\Erp\Model;

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

class Config
{
    private const XML_PATH_ENABLED = 'mageservices_erp/general/enabled';
    private const XML_PATH_API_URL = 'mageservices_erp/general/api_url';
    private const XML_PATH_API_KEY = 'mageservices_erp/general/api_key';
    private const XML_PATH_ORDER_STATUSES = 'mageservices_erp/general/order_statuses';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly EncryptorInterface $encryptor
    ) {
    }

    public function isEnabled(?int $websiteId = null): bool
    {
        return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED, ScopeInterface::SCOPE_WEBSITE, $websiteId);
    }

    public function getApiUrl(?int $websiteId = null): string
    {
        return rtrim((string) $this->scopeConfig->getValue(self::XML_PATH_API_URL, ScopeInterface::SCOPE_WEBSITE, $websiteId), '/');
    }

    public function getApiKey(?int $websiteId = null): string
    {
        $value = (string) $this->scopeConfig->getValue(self::XML_PATH_API_KEY, ScopeInterface::SCOPE_WEBSITE, $websiteId);

        return $value === '' ? '' : $this->encryptor->decrypt($value);
    }

    /**
     * @return string[]
     */
    public function getOrderStatuses(?int $websiteId = null): array
    {
        $value = (string) $this->scopeConfig->getValue(self::XML_PATH_ORDER_STATUSES, ScopeInterface::SCOPE_WEBSITE, $websiteId);

        return $value === '' ? [] : explode(',', $value);
    }
}

Setting Values From the Command Line

Configuration can be set in deployment scripts, which keeps environments consistent:

bin/magento config:set mageservices_erp/general/enabled 1
bin/magento config:set --scope=websites --scope-code=base mageservices_erp/general/api_url https://erp.example.com/api
bin/magento config:sensitive:set mageservices_erp/general/api_key "secret-value"
bin/magento config:show mageservices_erp/general/enabled

To keep secrets out of the database and version control entirely, mark the path as sensitive in di.xml and set it per environment in env.php:

<type name="Magento\Config\Model\Config\TypePool">
    <arguments>
        <argument name="sensitive" xsi:type="array">
            <item name="mageservices_erp/general/api_key" xsi:type="string">1</item>
        </argument>
    </arguments>
</type>

Field Types You Can Use

typeRenders
text, textareaSingle and multi-line input
select, multiselectDropdowns with a source_model
obscure, passwordHidden values such as API keys
image, fileUploads, with a matching backend model
labelRead-only information
CustomUse frontend_model for buttons, dynamic rows and other custom renderers

Custom Source Models

For dropdowns with your own options, create a class that implements Magento\Framework\Data\OptionSourceInterface and returns value/label pairs:

Model/Config/Source/SyncMode.php
<?php
declare(strict_types=1);

namespace MageServices\Erp\Model\Config\Source;

use Magento\Framework\Data\OptionSourceInterface;

class SyncMode implements OptionSourceInterface
{
    public function toOptionArray(): array
    {
        return [
            ['value' => 'realtime', 'label' => __('Real time (webhooks)')],
            ['value' => 'hourly', 'label' => __('Hourly')],
            ['value' => 'nightly', 'label' => __('Nightly')],
        ];
    }
}

Reference it with <source_model>MageServices\Erp\Model\Config\Source\SyncMode</source_model>. Keep option values stable — they are stored in the database, so changing them later breaks saved configuration.

Validating Values on Save

To validate or transform a value when the admin saves the form, add a backend model that extends Magento\Framework\App\Config\Value and override beforeSave(). Throw a LocalizedException with a clear message if the value is invalid, for example when an API URL does not use HTTPS.

Frequently Asked Questions

Why does my configuration section not appear?

Check the ACL resource exists and your admin role has it, then flush the cache. A typo in the section resource also hides the section.

What is the difference between getValue and isSetFlag?

getValue returns the raw stored value; isSetFlag casts it to a boolean, which is convenient for yes/no fields.

How do I make a field store-view specific?

Set showInStore="1" on the section, group and field, then read it with ScopeInterface::SCOPE_STORE.

🚀

Need help with this on your store?

Our Magento engineers can implement it for you, review your code or take on the whole project.

Explore Magento Extension Development
MS
About the author

Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.