Config overrides¶
Give a site its own value of any configuration object, from a setting, from code or from settings.php, and know which value wins.
The site builder's view is Configuration overrides per site.
The layers¶
On a site's host, sites.overrider (SiteConfigOverrider, a
config.factory.override service at priority -150) merges three per-site
sources, key by key. A configuration value comes from the strongest layer
that sets it:
$config['name']insettings.php: core's own override, on every site and the hub.- The site's entries in
settings.php($settings['acme']), as far as they reach configuration (SettingsPhpOverridesInterface). - The site's stored override rows (
site_config_override). - What the site derives: the
configOverrides()of its Site plugin and setting plugins, such as site type defaults, the analytics container, or a code-defined site. - Overriders of lower priority, such as core's configuration translation (-254).
- The active configuration.
Every configuration object read in a site context varies by the site and
languages:language_interface cache contexts and carries the active site's
cache tags and site_config_override_list:{site_id}.
A setting that targets configuration¶
A setting plugin key with a config_target is stored
as an override row of the site. The setting of sites_robotstxt:
public function defineSettings(): array {
return [
'robotstxt' => [
'label' => new TranslatableMarkup('robots.txt content'),
'default' => '',
'config_target' => 'robotstxt.settings:content',
],
];
}
The target is config.name:property.path, or a core
Drupal\Core\Form\ConfigTarget whose fromConfig and toConfig
callbacks convert the value (SystemSite uses one for the site mail).
When the site is saved, ConfigTargetSettings writes each such value as a
row keyed like its settings item: the default translation as language und,
a translation as its langcode, the item's environment as the environment,
with provider site_settings. An empty value withdraws the row. Reading goes
back the same way: getSetting() on the site returns the effective value.
A translatable flag on such a key only decides which translation pane of
the form shows it.
Stored rows from code¶
Integrations that offer their own form write rows through sites.site_settings
(SiteSettingsInterface), the way sites_menu stores the per-site menu
label:
use Drupal\sites\Section\SettingLayer;
$settings = \Drupal::service('sites.site_settings');
$settings->setConfigOverride(SettingLayer::base('acme'), 'system.menu.main', ['label' => 'Acme navigation']);
$settings->setConfigOverride(SettingLayer::environmentTranslation('acme', 'stage', 'de'), 'system.site', ['slogan' => 'Acme auf Stage']);
$settings->setConfigOverride(SettingLayer::base('acme'), 'system.menu.main', []); // withdraws
$settings->configOverrides(SettingLayer::base('acme')); // the rows of one layer
setConfigOverride() records a revision of the site and throws
\InvalidArgumentException for a layer the site does not have, an object a
setting targets, a role, the suite's own configuration (sites.*,
sites_*.*), or data outside the object's schema. It checks no access;
configOverrideAccess($account) gives the platform administrator's answer.
Below it, sites.config_override_repository
(SiteConfigOverrideRepositoryInterface) is the store itself. It refuses no
configuration name and leaves the site's revision to the site's next save:
$overrides = \Drupal::service('sites.config_override_repository');
$overrides->set('acme', 'system.site', ['slogan' => 'Acme auf Stage'], 'de', 'stage');
$overrides->get('acme', 'system.site', 'de', 'stage'); // the row of one exact key, or NULL
$overrides->resolve('acme'); // merged for the current language and environment
$overrides->delete('acme', 'system.site', 'de', 'stage');
A row is keyed by site, configuration name, language and environment;
language und applies to every language, environment default is the base
every environment inherits. OverrideLayers::merge() stacks them with the
environment outside and the language inside:
(und, default) < (de, default) < (und, stage) < (de, stage)
Things to know:
- Every key path and value is validated against the object's schema;
refusal()returns the reason without writing. - Lists merge by position, as core merges overrides: a shorter list does not shrink the active one. Switch something off with a boolean or a dedicated key.
- Rows belong to the site, like its content: they are never part of the configuration export. They follow a renamed configuration object and are withdrawn with a deleted one.
- Every change is a revision of the row. Withdrawing one is a revision too,
and
stateAt()reads the rows as they were at a site revision. No page lists these revisions, and nothing reverts to one. Deleting the site deletes its rows.
Kernel tests that save sites install the entity schema:
$this->installEntitySchema('site_config_override');.
settings.php wins¶
A site's entries in settings.php win over its stored rows:
$settings['acme'] = [
'site_name' => 'Acme (staging)',
'config_overrides' => [
'system.performance' => ['css' => ['preprocess' => FALSE]],
],
];
A setting key with a config_target overrides only the paths it targets;
a key settings.php leaves alone keeps its stored value. A
config_overrides entry applies as it is. The admin UI keeps showing the
stored value. SitePluginBase implements
SettingsPhpOverridesInterface::getSettingsPhpConfigOverrides(); a Site
plugin that does not extend it implements the interface to take part.
An overrider of your own¶
For values computed per request, a module adds its own
config.factory.override service. A higher priority is a stronger layer.
The overrider is a dependency of config.factory, so it must not take
anything built on config.factory (the language manager, the module
handler, the entity type manager, plugin managers) as a constructor
argument: that is a container cycle, at compile time or only on the next
full rebuild, like drush pm:uninstall. Pass such services as
!service_closure and resolve them inside loadOverrides():
services:
my_module.overrider:
class: Drupal\my_module\Config\MyOverrider
arguments: ['@current_site', !service_closure '@my_module.values']
tags:
- { name: config.factory.override, priority: -160 }
namespace Drupal\my_module\Config;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryOverrideInterface;
use Drupal\Core\Config\StorageInterface;
use Drupal\sites\SiteProxyInterface;
final class MyOverrider implements ConfigFactoryOverrideInterface {
private bool $building = FALSE;
public function __construct(
private readonly SiteProxyInterface $currentSite,
private readonly \Closure $values,
) {}
public function loadOverrides($names): array {
if ($this->building || !$this->currentSite->isReady() || $this->currentSite->isHub()) {
return [];
}
// Reading the values may load configuration, which calls back in here.
$this->building = TRUE;
try {
return array_intersect_key(($this->values)()->forSite((string) $this->currentSite->id()), array_flip($names));
}
finally {
$this->building = FALSE;
}
}
public function getCacheSuffix(): string {
return $this->currentSite->isReady() ? (string) $this->currentSite->id() : '';
}
public function getCacheableMetadata($name): CacheableMetadata {
return (new CacheableMetadata())->addCacheContexts(['site']);
}
public function createConfigObject($name, $collection = StorageInterface::DEFAULT_COLLECTION) {
return NULL;
}
}
SiteConfigOverrider in sites/src/Config is the full example, including
the cache metadata for every name it may override.