Configuration Types in Depth: Custom Config Sources Beyond System/Config
Configuration Types in Depth: Custom Config Sources Beyond System/Config
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapter 7 built a System/Config/Setting page: four values under mironsoft_loyalty/general/*, stored in core_config_data, read through ScopeConfigInterface. That covers everything a shop operator should manage themselves in the admin. But there's a class of settings for which this is the wrong tool: a global kill switch that stops reward redemption immediately if something looks wrong in the ledger - a setting an operations team sets through deployment, not a merchant through an admin form, and one that ideally stays readable even when the database is in a questionable state. Magento offers a second, less commonly used extension point for exactly that: custom configuration types.
How ScopeConfigInterface really works
ScopeConfigInterface::getValue() from chapter 7 is just a convenient facade. Underneath sits Magento\Framework\App\Config, a class that manages several named configuration types - each an implementation of Magento\Framework\App\Config\ConfigTypeInterface. Magento core registers several out of the box, among them system (system.xml + core_config_data, the one chapter 7 uses), env (env.php), and default (config.php). ScopeConfigInterface::getValue() ultimately queries exactly one of these types, defaulting to system. What's new here is only the idea: nothing stops a custom module from adding its own, fourth type to that list.
ConfigSourceInterface vs. ConfigTypeInterface
Two different interfaces, two different jobs. Magento\Framework\App\Config\ConfigSourceInterface is the raw source - a single method get(string $path = ''): array that returns the complete, un-cached data tree (read a file, read an environment variable, query an external service). Magento\Framework\App\Config\ConfigTypeInterface is the registered, cached facade on top of it, with a get($path = '') method addressed with slash paths like redemption/kill_switch, exactly like ScopeConfigInterface::getValue(). One source and one type are enough for this chapter.
The source: a custom app/etc file
Just like env.php and config.php, a plain PHP array file under app/etc/ is the simplest source: versionable through Git, deployable through CI, readable independently of any database connection.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Config\Source;
use Magento\Framework\App\Config\ConfigSourceInterface;
use Magento\Framework\Filesystem\DirectoryList;
use Magento\Framework\Filesystem\Driver\File;
/**
* Reads deploy-controlled loyalty feature flags from a plain PHP array file,
* following the same app/etc/*.php convention as env.php and config.php.
*/
class LoyaltyFeatureFlagsFileSource implements ConfigSourceInterface
{
private const FILE_NAME = 'loyalty_flags.php';
/**
* @param DirectoryList $directoryList Resolves the absolute path to app/etc.
* @param File $fileDriver Checks file existence without a require()/autoload detour.
*/
public function __construct(
private readonly DirectoryList $directoryList,
private readonly File $fileDriver,
) {
}
/**
* Loads the full flags array. A missing file is a valid, deploy-time
* "everything at its default" state, not an error.
*
* @param string $path Ignored - this source always returns its full tree, per ConfigSourceInterface's contract.
* @return array
*/
public function get(string $path = ''): array
{
$filePath = $this->directoryList->getPath(DirectoryList::CONFIG) . '/' . self::FILE_NAME;
if (!$this->fileDriver->isExists($filePath)) {
return [];
}
/** @var array<string, mixed>|false $flags */
$flags = include $filePath;
return is_array($flags) ? $flags : [];
}
}<?php
declare(strict_types=1);
return [
'redemption' => [
'kill_switch' => false,
],
];The type: caching and path resolution
ConfigTypeInterface deliberately declares only a single method - caching strategy and path resolution are each implementation's own responsibility. The custom type here caches the loaded data in the same cache instance and under the same tag as Magento's own configuration cache.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Config;
use Magento\Framework\App\Cache\Type\Config as ConfigCacheType;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\App\Config\ConfigSourceInterface;
use Magento\Framework\App\Config\ConfigTypeInterface;
use Magento\Framework\Serialize\Serializer\Json;
/**
* A brand new configuration type, independent of the built-in "system"/"env"/
* "default" types: deploy-controlled loyalty feature flags, cached under the same
* core "config" cache tag so bin/cache-clean config busts it too.
*/
class LoyaltyFeatureFlagsConfigType implements ConfigTypeInterface
{
/**
* @var string
*/
public const CONFIG_TYPE = 'mironsoft_loyalty_flags';
private const CACHE_ID = 'mironsoft_loyalty_flags';
/**
* @var array<string, mixed>|null
*/
private ?array $data = null;
/**
* @param ConfigSourceInterface $source Raw, un-cached data source (see above).
* @param CacheInterface $cache Application cache pool, reused with the core config cache tag.
* @param Json $serializer Serializes the flags array for the cache entry.
*/
public function __construct(
private readonly ConfigSourceInterface $source,
private readonly CacheInterface $cache,
private readonly Json $serializer,
) {
}
/**
* Resolves a slash-separated path against the cached flags tree, e.g.
* "redemption/kill_switch". An empty path returns the full tree.
*
* @param string $path Slash-separated path into the flags array.
* @return mixed
*/
public function get($path = '')
{
if ($this->data === null) {
$this->data = $this->loadData();
}
if ($path === '') {
return $this->data;
}
$value = $this->data;
foreach (explode('/', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
/**
* Reads the flags tree from cache, or from the source on a cache miss.
*
* @return array<string, mixed>
*/
private function loadData(): array
{
$cached = $this->cache->load(self::CACHE_ID);
if ($cached !== false) {
/** @var array<string, mixed> $decoded */
$decoded = $this->serializer->unserialize($cached);
return $decoded;
}
$data = $this->source->get();
$this->cache->save($this->serializer->serialize($data), self::CACHE_ID, [ConfigCacheType::CACHE_TAG]);
return $data;
}
}get($path = '') deliberately stays without parameter and return types - just as AbstractBackend::beforeSave($object) did in chapter 26, a loose interface signature forces an equally loose implementation; PHP forbids tightening a type on an interface method afterward.
Registering in di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Framework\App\Config">
<arguments>
<argument name="types" xsi:type="array">
<item name="mironsoft_loyalty_flags" xsi:type="object">Mironsoft\Loyalty\Model\Config\LoyaltyFeatureFlagsConfigType</item>
</argument>
</arguments>
</type>
</config>Achtung: The types array item is a constructor argument of Magento\Framework\App\Config, an already-compiled core class - a new entry only takes effect after bin/magento setup:di:compile, even in developer mode. Adding it and only running bin/cache-clean still won't make the new type appear.
Usage
The new type is consumed through Magento\Framework\App\Config::get() - not to be confused with ScopeConfigInterface::getValue(), which is always hard-wired to the system type. The first parameter is the type name from the di.xml above.
// Illustrative excerpt - not a change to the redeem controller fixed in chapter 50.
public function __construct(
private readonly \Magento\Framework\App\Config $appConfig,
// ...
) {
}
private function assertRedemptionAllowed(): void
{
if ((bool) $this->appConfig->get(LoyaltyFeatureFlagsConfigType::CONFIG_TYPE, 'redemption/kill_switch')) {
throw new \Magento\Framework\Exception\LocalizedException(
__('Reward redemption is temporarily disabled.')
);
}
}Tipp: The call to __('Reward redemption is temporarily disabled.') above is no accident - strings exactly like it are the subject of the next two chapters.
Where this differs from chapter 7
- system.xml (chapter 7): the merchant should manage the value themselves in the admin, scoped per website/store, versioned in
core_config_data-points_per_euro,points_expiry_months. - Custom configuration type (this chapter): ops/deployment controls the value, globally, file-based, readable independently of the database - a kill switch, a feature flag for an ongoing migration, a value deliberately kept out of reach of the admin form.
Chapters 89 and 90 turn next to a very different kind of configuration: not values, but strings - this module's multi-language support.