Skip to content

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:

  1. $config['name'] in settings.php: core's own override, on every site and the hub.
  2. The site's entries in settings.php ($settings['acme']), as far as they reach configuration (SettingsPhpOverridesInterface).
  3. The site's stored override rows (site_config_override).
  4. 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.
  5. Overriders of lower priority, such as core's configuration translation (-254).
  6. 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.