Dynamic Store Configuration Fields

Magento’s store configuration functionality allows developers to quickly and efficiently define config fields for their modules. This efficiency promotes flexible and configurable modules, saving developers and merchants time and money.

In some cases, however, a fixed list of defined config fields isn’t sufficient to configure a more dynamic feature. Fortunately, it’s relatively straightforward to implement dynamically generated store config fields, allowing developers to support complicated flexibility.

[cta connected_to=”post” prefix=”Related:” count=”1″][/cta]

Example Use Case

The requirement for this example is that, for countries which are configured to require a state/region, only certain regions are allowed.

These countries are configured in store config at General -> General -> State Options -> State is Required for. In order to configure which regions are allowed for each country, new store config fields will need to be added, one for each configured country. Since the configured countries could change at any time, it will not be possible to hard code the allowed regions fields in system.xml. Instead, a field will need to be dynamically added to the State Options group for each configured country.

Dynamic Fields

Approach

Each element in the store config hierarchy (tab, section, and group) extends MagentoConfigModelConfigStructureElementAbstractComposite. When setting its data, AbstractComposite specifically looks for an array at key children, and uses this value to populate the children elements.

public function setData(array $data, $scope)
{
    parent::setData($data, $scope);
    $children = array_key_exists(
        'children',
        $this->_data
    ) && is_array(
        $this->_data['children']
    ) ? $this->_data['children'] : [];
    $this->_childrenIterator->setElements($children, $scope);
}

This provides the opportunity to craft a plugin to add (or remove) children, which will eventually be used to populate the store config UI. The parent element of fields is a config group, so MagentoConfigModelConfigStructureElementGroup is the specific class where calls to setData() should be intercepted.




    
        
    

Now that an interception point has been established, all that's needed is to add values to the $data['children'] array.

The format for the values of this array mostly follow the format of fields in system.xml, with a few additions. Below is an example with common values set on the field.

[
    'id' => 'field-config-path-id',     // Dictates store config XML path
    'type' => 'multiselect',            // Sets type of config field
    'sortOrder' => 50,                  // Sort order of field
    'showInDefault' => '1',             // Show in default scope
    'showInWebsite' => '0',             // Show in website scope
    'showInStore' => '0',               // Show in store scope
    'label' => __('My Dynamic Field'),  // Label of field shown in admin
    'options' => [                      // Define field values for types which support fixed values.
                                        // Alternatively, source_model could be used.
        'option' => [
            'value1' => [
                [
                    'value' => 'value1',
                    'label' => 'Label 1'
                ]
            ],
            'value2' => [
                [
                    'value' => 'value2',
                    'label' => 'Label 2'
                ]
            ]
        ]
    ],
    'comment' => __(                    // Field comment
        'This field was generated dynamically'
    ),
    '_elementType' => 'field',          // Fixed value of 'field'
    'path' => 'some-tab/some-group'     // Config XML path prefix.
                                        // This value + '/' + value of id field comprise final config path.
]

In this example, a field needs to be dynamically added for every selected country in the General -> General -> State Options -> State is Required for field. Using MagentoDirectoryHelperData to retrieve which countries are selected and their known regions (if any), the plugin shell can generate these dynamic fields.

In the event that a country has a known list of regions, a multiselect field is used. Otherwise, a textarea field is generated to allow free-form region entry.

 General -> State Options group.
 *
 * @package EWDynamicConfigFieldsModelConfigConfigStructureElement
 */
class Group
{
    /**
     * Config XML path of target group
     */
    const DIRECTORY_REGION_REQUIRED_GROUP_ID = 'region';

    /**
     * @var MagentoDirectoryHelperData
     */
    protected $directoryHelper;
    /**
     * @var CountryInformationAcquirerInterface
     */
    protected $countryInformationAcquirer;

    /**
     * Group constructor.
     * @param DirectoryHelper $directoryHelper
     * @param CountryInformationAcquirerInterface $countryInformationAcquirer
     */
    public function __construct(
        DirectoryHelper $directoryHelper,
        CountryInformationAcquirerInterface $countryInformationAcquirer
    )
    {
        $this->directoryHelper = $directoryHelper;
        $this->countryInformationAcquirer = $countryInformationAcquirer;
    }

    /**
     * Get config options array of regions for given country
     *
     * @param CountryInformationInterface $countryInfo
     * @return array
     */
    protected function getRegionsForCountry(CountryInformationInterface $countryInfo) : array {
        $options = [];

        $availableRegions = $countryInfo->getAvailableRegions() ?: [];

        foreach($availableRegions as $region) {
            $options[$region->getCode()] = [
                'value' => $region->getCode(),
                'label' => $region->getName()
            ];
        }

        return $options;
    }

    /**
     * Get dynamic config fields (if any)
     *
     * @return array
     */
    protected function getDynamicConfigFields() : array {
        $countriesWithStatesRequired = $this->directoryHelper->getCountriesWithStatesRequired();

        $dynamicConfigFields = [];
        foreach($countriesWithStatesRequired as $index => $country) {
            // Use a consistent prefix for dynamically generated fields
            // to allow them to be deterministic but not collide with any
            // preexisting fields.
            // ConfigHelper::ALLOWED_REGIONS_CONFIG_PATH_PREFIX == 'regions-allowed-'.
            $configId = ConfigHelper::ALLOWED_REGIONS_CONFIG_PATH_PREFIX . $country;

            $countryInfo = $this->countryInformationAcquirer->getCountryInfo($country);
            $regionOptions = $this->getRegionsForCountry($countryInfo);

            // Use type multiselect if fixed list of regions; otherwise, use textarea.
            $configType = !empty($regionOptions) ? 'multiselect' : 'textarea';

            switch($configType) {
                case 'multiselect':
                    $dynamicConfigFields[$configId] = [
                        'id' => $configId,
                        'type' => 'multiselect',
                        'sortOrder' => ($index * 10), // Generate unique and deterministic sortOrder values
                        'showInDefault' => '1',       // In this case, only show fields at default scope
                        'showInWebsite' => '0',
                        'showInStore' => '0',
                        'label' => __('Allowed Regions: %1', $countryInfo->getFullNameEnglish()),
                        'options' => [                // Since this is a multiselect, generate options dynamically.
                            'option' => $this->getRegionsForCountry($countryInfo)
                        ],
                        'comment' => __(
                            'Select allowed regions for %1.',
                            $countryInfo->getFullNameEnglish()
                        ),
                        '_elementType' => 'field',
                        'path' => ConfigHelper::ALLOWED_REGIONS_GROUP_PATH_PREFIX // Tab/section name: 'general/region'.
                    ];
                    break;
                case 'textarea':
                    $dynamicConfigFields[$configId] = [
                        'id' => $configId,
                        'type' => 'textarea',
                        'sortOrder' => ($index * 10),  // Generate unique and deterministic sortOrder values
                        'showInDefault' => '1',        // In this case, only show fields at default scope
                        'showInWebsite' => '0',
                        'showInStore' => '0',
                        'label' => __('Allowed Regions: %1', $countryInfo->getFullNameEnglish()),
                        'comment' => __(
                            'Enter allowed regions for %1, one per line.',
                            $countryInfo->getFullNameEnglish()
                        ),
                        '_elementType' => 'field',
                        'path' => ConfigHelper::ALLOWED_REGIONS_GROUP_PATH_PREFIX // Tab/section name: 'general/region'.
                    ];
                    break;
            }

        }

        return $dynamicConfigFields;
    }

    /**
     * Add dynamic region config fields for each country configured
     *
     * @param OriginalGroup $subject
     * @param callable $proceed
     * @param array $data
     * @param $scope
     * @return mixed
     */
    public function aroundSetData(OriginalGroup $subject, callable $proceed, array $data, $scope) {
        // This method runs for every group.
        // Add a condition to check for the one to which we're
        // interested in adding fields.
        if($data['id'] == self::DIRECTORY_REGION_REQUIRED_GROUP_ID) {
            $dynamicFields = $this->getDynamicConfigFields();

            if(!empty($dynamicFields)) {
                $data['children'] += $dynamicFields;
            }
        }

        return $proceed($data, $scope);
    }
}

Results

After fully implementing the plugin, dynamically created country-specific config fields are appended to the State Options group.

In the following screenshot, the default countries are selected, along with Afghanistan, for good measure. (Click for full page screenshot.)

Abbreviated Dynamic Fields Admin UI

Of course, adding dynamic fields is useless if they don't store the field values. This is handled seamlessly by Magento, and the dynamic fields' values are stored in core_config_data with the computed path.

core_config_data dynamic fields screenshot

These values can be retrieved by their computed paths in the same way as any other store config value.

Dynamic Groups

For some applications with multiple sets of fields, it may make more sense to create entire groups of fields dynamically. This can be done in a similar way, one level higher in the store config hierarchy.

Approach

Just as dynamic fields are added by a plugin around the group model's setData() method, dynamic groups are added by a plugin around the setData() method of the section model, MagentoConfigModelConfigStructureElementSection. This plugin is nearly identical, but the expected format of arrays appended to $data['children'] is one level higher.

[
    'id' => 'group-config-path-id,      // Group ID component of config path
    'label' => __('My Dynamic Group'),  // Group label
    'showInDefault' => '1',             // Show group in default scope
    'showInWebsite' => '0',             // Show group in website scope
    'showInStore' => '0',               // Show group in store scope
    'sortOrder' => 50,                  // Group sort order
    'children' => []                    // Children fields, using identical format as described above
]

In this example, it's possible to add an entire config group for each country selected by using this format. This is easily implemented using a plugin similar to the dynamic fields plugin.




    
        
    
 General section.
 *
 * @package EWDynamicConfigFieldsModelConfigConfigStructureElement
 */
class Section
{
    /**
     * Config path of target section
     */
    const CONFIG_GENERAL_SECTION_ID = 'general';

    /**
     * @var MagentoDirectoryHelperData
     */
    protected $directoryHelper;
    /**
     * @var CountryInformationAcquirerInterface
     */
    protected $countryInformationAcquirer;

    /**
     * Group constructor.
     * @param DirectoryHelper $directoryHelper
     * @param CountryInformationAcquirerInterface $countryInformationAcquirer
     */
    public function __construct(
        DirectoryHelper $directoryHelper,
        CountryInformationAcquirerInterface $countryInformationAcquirer
    )
    {
        $this->directoryHelper = $directoryHelper;
        $this->countryInformationAcquirer = $countryInformationAcquirer;
    }

    /**
     * Get config options array of regions for given country
     *
     * @param CountryInformationInterface $countryInfo
     * @return array
     */
    protected function getRegionsForCountry(CountryInformationInterface $countryInfo) : array {
        $options = [];

        $availableRegions = $countryInfo->getAvailableRegions() ?: [];

        foreach($availableRegions as $region) {
            $options[$region->getCode()] = [
                'value' => $region->getCode(),
                'label' => $region->getName()
            ];
        }

        return $options;
    }

    /**
     * Get dynamic config groups (if any)
     *
     * @return array
     */
    protected function getDynamicConfigGroups() : array {
        $countriesWithStatesRequired = $this->directoryHelper->getCountriesWithStatesRequired();

        $dynamicConfigGroups = [];
        foreach($countriesWithStatesRequired as $index => $country) {
            // Use a consistent prefix for dynamically generated fields
            // to allow them to be deterministic but not collide with any
            // preexisting fields.
            // ConfigHelper::ALLOWED_REGIONS_CONFIG_PATH_PREFIX == 'regions-allowed-'.
            $configId = ConfigHelper::ALLOWED_REGIONS_CONFIG_PATH_PREFIX . $country;

            $countryInfo = $this->countryInformationAcquirer->getCountryInfo($country);
            $regionOptions = $this->getRegionsForCountry($countryInfo);

            // Use type multiselect if fixed list of regions; otherwise, use textarea.
            $configType = !empty($regionOptions) ? 'multiselect' : 'textarea';

            $dynamicConfigFields = [];
            switch($configType) {
                case 'multiselect':
                    $dynamicConfigFields[$configId] = [
                        'id' => $configId,
                        'type' => 'multiselect',
                        'sortOrder' => ($index * 10), // Generate unique and deterministic sortOrder values
                        'showInDefault' => '1',       // In this case, only show fields at default scope
                        'showInWebsite' => '0',
                        'showInStore' => '0',
                        'label' => __('Allowed Regions: %1', $countryInfo->getFullNameEnglish()),
                        'options' => [                // Since this is a multiselect, generate options dynamically.
                            'option' => $this->getRegionsForCountry($countryInfo)
                        ],
                        'comment' => __(
                            'Select allowed regions for %1.',
                            $countryInfo->getFullNameEnglish()
                        ),
                        '_elementType' => 'field',
                        'path' => implode(            // Compute group path from section ID and dynamic group ID
                            '/',
                            [
                                self::CONFIG_GENERAL_SECTION_ID,
                                ConfigHelper::ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX . $country
                            ]
                        )
                    ];
                    break;
                case 'textarea':
                    $dynamicConfigFields[$configId] = [
                        'id' => $configId,
                        'type' => 'textarea',
                        'sortOrder' => ($index * 10), // Generate unique and deterministic sortOrder values
                        'showInDefault' => '1',       // In this case, only show fields at default scope
                        'showInWebsite' => '0',
                        'showInStore' => '0',
                        'label' => __('Allowed Regions: %1', $countryInfo->getFullNameEnglish()),
                        'comment' => __(
                            'Enter allowed regions for %1, one per line.',
                            $countryInfo->getFullNameEnglish()
                        ),
                        '_elementType' => 'field',
                        'path' => implode(            // Compute group path from section ID and dynamic group ID
                            '/',
                            [
                                self::CONFIG_GENERAL_SECTION_ID,
                                ConfigHelper::ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX . $country
                            ]
                        )
                    ];
                    break;
            }

            $dynamicConfigGroups[$country] = [    // Declare group information
                'id' => $country,                   // Use dynamic group ID
                'label' => __(
                    '%1 Allowed Regions',
                    $countryInfo->getFullNameEnglish()
                ),
                'showInDefault' => '1',             // Show in default scope
                'showInWebsite' => '0',             // Don't show in website scope
                'showInStore' => '0',               // Don't show in store scope
                'sortOrder' => ($index * 10),       // Generate unique and deterministic sortOrder values
                'children' => $dynamicConfigFields  // Use dynamic fields generated above
            ];
        }

        return $dynamicConfigGroups;
    }

    /**
     * Add dynamic region config groups for each country configured
     *
     * @param OriginalSection $subject
     * @param callable $proceed
     * @param array $data
     * @param $scope
     * @return mixed
     */
    public function aroundSetData(OriginalSection $subject, callable $proceed, array $data, $scope) {
        // This method runs for every section.
        // Add a condition to check for the one to which we're
        // interested in adding groups.
        if($data['id'] == self::CONFIG_GENERAL_SECTION_ID) {
            $dynamicGroups = $this->getDynamicConfigGroups();

            if(!empty($dynamicGroups)) {
                $data['children'] += $dynamicGroups;
            }
        }

        return $proceed($data, $scope);
    }
}

Results

Similar to dynamic fields, after this plugin is implemented a dynamic group is shown in the General -> General tab, one for each selected country. (Click for full page screenshot.)

Screenshot of Dynamic Groups in Admin UI

Additionally, values of fields in dynamic groups are correctly saved to core_config_data.

core_config_data dynamic groups screenshot

Where to Go From Here

Retrieving Values

Retrieving values of dynamic fields or fields of dynamic groups is the same as getting any other store config field, except that the path is computed. Below is an example of a config helper to look up the values from this example.

scopeConfig->getValue($configPath, $scopeType, $scopeCode);

        // Split on either comma or newline to accommodate both multiselect
        // and textarea field types.
        $parsedValues = preg_split('/[,n]/', $rawValue);

        return $parsedValues;
    }

    /**
     * Get configured allowed regions from fields in dynamic groups
     *
     * @param string $countryCode
     * @param string $scopeType
     * @param null $scopeCode
     * @return array
     */
    public function getAllowedRegionsByDynamicGroup(
        string $countryCode,
        $scopeType = ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
        $scopeCode = null
    ) : array {
        $configPath = implode(
            '/',
            [
                self::ALLOWED_REGIONS_TAB_ID,
                self::ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX . $countryCode,
                self::ALLOWED_REGIONS_CONFIG_PATH_PREFIX . $countryCode
            ]
        );

        $rawValue = $this->scopeConfig->getValue($configPath, $scopeType, $scopeCode);

        // Split on either comma or newline to accommodate both multiselect
        // and textarea field types.
        $parsedValues = preg_split('/[,n]/', $rawValue);

        return $parsedValues;
    }
}

Dynamic Sections and Tabs

Similar to fields and groups, it's possible to create an around plugin on the setData() method of MagentoConfigModelConfigStructureElementTab and add children one level higher than a group. These dynamic sections will show in the admin as expected. However, clicking on them redirects back to one of the hard-coded sections. Since each section has its own URL, there are probably additional routing concerns for dynamic sections.

Example Module

A complete module demonstrating these code examples is available here: https://github.com/ericthehacker/example-dynamicconfigfields. Use it wisely.

Be aware of the following module notes.

  • The module implements example dynamic fields and groups, as well as methods to retrieve their values. Actually using values to restrict available regions (as expressed in the example use case), however, is left as an exercise for the reader.
  • There is duplicated code between the dynamic fields and groups plugins. This is intentional to ensure that each example plugin is easy to read.

Share it

Topics

Related Posts

Affordable Uniforms

Five Things You Should Know About eCommerce Fraud

4 Ingredients for Creating Product Descriptions that Sell

Satisfying a Niche: The Ins and Outs of Unique Markets

Contact Us