Configuration Management
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
This page was checked on 2026-10-30 against Drupal 11.4.x, Drush 13 and 14 and PHP 8.3 or later. Drupal 10 reaches end of life on 2026-12-09, and Drupal 12.0.0 is scheduled for the week of 2026-12-07, so nothing here recommends starting a new site on Drupal 10. The configuration system described below is the same on all of those versions; where a detail differs it is called out. The page is aimed at site builders, administrators and developers who need to move a site’s structure between a laptop, a continuous-integration job, staging and production without redoing it by hand.
The running example is the Meridian Museum, a fictitious regional science museum. Its site has a custom module
meridian_collection, a theme meridian, a recipe meridian_events and three environments: a DDEV site on each
developer’s laptop, a staging site and the production site. Every example below belongs to that site.
The problem configuration management solves
A Drupal site is made of two very different kinds of data. The first is content: the exhibitions, news items and collection objects that editors write every day and that exist only in the database of the site where they were written. The second is configuration: the content types and their fields, the views, the roles and permissions, the text formats, the enabled modules and themes, the image styles and every setting on an administration form. Configuration is what a developer builds once on a laptop and then needs to see on staging and production, identical to the last setting.
Before Drupal 8 that second kind of data was scattered over database tables, and the usual way to move it was to rebuild it by hand or to export it in code with a module such as Features. Since Drupal 8 the configuration is a first-class concept with its own API, its own storage and a synchronisation workflow: export the active configuration to YAML files, commit them to Git, and import them on the other environment. The official overview of the idea is the Drupal User Guide chapter on extending and customising a site (sections 11.9 and 11.10) and the Configuration API documentation.
Three terms are often confused, and the distinction drives everything else on this page:
-
Configuration is data that describes how the site is built and that should travel from development to production. It is usually created while building the site and not by ordinary visitors.
-
Content is data that belongs to the site where it is created: nodes, users, comments, media items, files and taxonomy terms. Content entities are not exported by configuration management.
-
State (the State API) holds values that are specific to one environment and never synchronised: the last cron run time, the maintenance-mode flag set by Drush, the timestamp of the last search index run. State does not belong in configuration, and resetting state does not touch configuration.
The Configuration API overview states the same split: configuration is the information meant to "synchronize from development to production", and values that should not move belong in the State API.
Source: Configuration API overview
Where configuration lives
Active storage
By default Drupal keeps the active configuration, the configuration the running site actually uses, in the database,
in the config table. Reading it from the database is faster and scales better than parsing files on every request.
The active configuration is reached through the config.factory service and, below it, the config.storage service,
which in a default install is backed by the database storage class. Nothing on disk is read at runtime for active
configuration, which is why editing a YAML file does nothing until an import is run.
The sync directory
The second location is the sync directory, a folder of .yml files, one per configuration object, that holds an
exported copy of the active configuration. It is the folder under version control. Its path is set in settings.php:
<?php
// settings.php
// A directory outside the web root keeps the exported files out of reach of visitors.
$settings['config_sync_directory'] = '../config/sync';
Source: Configuration Storage
A relative path is resolved from the Drupal root, so ../config/sync means a config/sync folder next to the web
directory in a Composer project, and it is the layout used by the drupal/recommended-project template and by DDEV. The
folder is read through the config.storage.sync service. The sync directory should not be inside web because the
files describe the site’s structure, its enabled modules and some environment details, and there is no reason to serve
them. See settings.php and Environments for the rest of the file and
Project Layout and Local Development for the folder layout.
Older tutorials written for Drupal 8.0 to 8.7 set the directory with $config_directories['sync'] or
$config_directories[CONFIG_SYNC_DIRECTORY], often pointing at a randomly named folder inside sites/default/files.
That variable was replaced by $settings['config_sync_directory'] in Drupal 8.8, and the old form no longer works.
Extension default configuration
The third location is inside modules, themes and install profiles. An extension ships its default configuration as YAML in two folders:
-
config/installis imported when the extension is installed, and installation fails if one of its dependencies is missing. -
config/optionalis imported on installation only for those files whose dependencies are all satisfied, and the rest are skipped silently. It is the right place for configuration that refers to an optional module, for example a view that needs the Search API module.
After installation the shipped files are only a starting point: the active copy lives in the database and the module’s
files are never read again, which is why changing config/install in an installed module needs an update function or a
manual import, not just a cache clear. Values that depend on runtime data are set in hook_install(), which, like
hook_uninstall(), must stay a procedural function in the .install file even where other hooks can be object-oriented.
The Configuration Storage page adds a workflow for developing a module’s configuration: edit the YAML in the sync folder,
run drush cim, run drush cr, and copy the result into the module’s config/install folder when it is right. In
practice most developers do the reverse, which is to build the configuration in the administration interface, export it
and copy the files that belong to the module.
Overview of the places
Simple configuration and configuration entities
The Configuration API has two flavours, and the choice is made by the module author.
Simple configuration is for singletons: exactly one object per name. The site name and slogan in system.site, the
performance settings in system.performance and the settings of the Meridian module in meridian_collection.settings
are simple configuration. The file name is the object name plus .yml, and the name is conventionally
module_name.something.
Configuration entities are for sets: many objects of one type, each identified by an ID. Content types (node.type.*),
fields (field.field.* and field.storage.*), views (views.view.*), roles (user.role.*), image styles, text formats
and block placements are configuration entities. They have a UUID, a label, a dependency list and the ability to be
created, edited and deleted through the Entity API.
A module’s own settings might look like this. The first file is shipped in the module and the second is what a configuration export produces for a configuration entity that the museum’s editors build in the interface.
# modules/custom/meridian_collection/config/install/meridian_collection.settings.yml
# Simple configuration: one object, named after the file.
sync_enabled: true
api_base_url: 'https://collections.example.test/api/v2'
batch_size: 50
display_policy:
show_on_display_only: true
default_gallery: 'main-hall'
# config/sync/node.type.exhibition.yml
# A configuration entity exported by drush config:export.
uuid: 5b1f2d9a-3e5e-4a6f-9d21-0b4f8a7c1e11
langcode: en
status: true
dependencies:
module:
- menu_ui
third_party_settings:
menu_ui:
available_menus:
- main
parent: 'main:'
name: Exhibition
type: exhibition
description: 'A temporary or permanent exhibition with dates, a gallery and related objects.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: false
Source: Simple Configuration API
Two practical consequences follow. Simple configuration has no uuid of its own, and its identity is its name. A
configuration entity with the same ID on two sites is only the same object if the UUIDs match, which is the subject of the
section on UUIDs below. Code that reads and writes configuration, the config factory, overrides, the Config and
ConfigEntityBase classes and how to write a configuration entity type, is covered in
Configuration API and Schema; this page treats it from the point of
view of moving configuration around.
Reading and writing simple configuration from code and from the command line is quick to try:
# Show a value; --include-overridden shows the value after settings.php overrides.
ddev drush config:get system.site name
ddev drush config:get system.performance --include-overridden
# Change one value in the active configuration (then export it).
ddev drush config:set system.site name 'Meridian Museum' -y
ddev drush config:set meridian_collection.settings batch_size 100 -y
# Edit a whole object in $EDITOR.
ddev drush config:edit meridian_collection.settings
Source: Drush: config:set
Configuration schema
Every configuration object that Drupal core and well-behaved modules write has a schema: a description, in YAML, of the
keys the object may contain and the type of each value. Schema files live in the config/schema directory of the
extension and are named module_name.schema.yml. Shared base types such as config_object, config_entity, label,
text and boolean are defined in core/config/schema/core.data_types.schema.yml.
The schema exists for four reasons:
-
Typecasting. When configuration is saved the values are cast to the declared types, so
'1'becomes1for an integer key. -
Translation. Only values whose type is translatable (
label,textanddate_format) appear on the configuration translation forms. Declaring a string key asstringmakes it invisible to translators, whilelabelortextmakes it translatable. This is the schema mistake most often seen in custom modules, and it is described with the multilingual workflow in Multilingual. -
Validation. Schema-aware validation constraints check imported and edited configuration. Tests can enforce strict schema checking, which turns a missing schema into a failing test.
-
Recipes and config actions. Recipes use the schema to apply configuration actions safely, see Recipes.
A schema for the museum’s settings file above:
# modules/custom/meridian_collection/config/schema/meridian_collection.schema.yml
meridian_collection.settings:
type: config_object
label: 'Meridian collection settings'
mapping:
sync_enabled:
type: boolean
label: 'Synchronise objects from the collections API'
api_base_url:
type: uri
label: 'Collections API base URL'
batch_size:
type: integer
label: 'Objects per batch'
display_policy:
type: mapping
label: 'Display policy'
mapping:
show_on_display_only:
type: boolean
label: 'Show only objects on display'
default_gallery:
type: string
label: 'Default gallery'
Source: Configuration schema/metadata
The vocabulary used above: a mapping is a key-value structure in which every key is declared and has its own type; a
sequence is a list whose keys are not known in advance, so the schema declares only the type of the values and may set
orderby: key or orderby: value to sort the entries on save. The basic types are boolean, email, integer,
float, string and uri; core adds label, plural_label, text, uuid, path, date_format, color_hex,
machine_name and weight. When a key has no schema its type is undefined, and ignore means that no typing is
possible. The _core key, which holds the default_config_hash of configuration installed from an extension, and
everything under it is reserved for Drupal core, and langcode is inherited from the config_object base type.
A missing or wrong schema is not reported by a normal request. It surfaces as an error when the configuration
translation form is opened, as an SchemaIncompleteException in tests that enable strict checking, or as a value that
silently changes type. The Configuration Inspector contrib module lists schema problems for the whole site and is a
standard tool before any release.
UUIDs and the site UUID
Each configuration entity carries a uuid. When an import finds a file whose name matches an existing entity but whose
uuid differs, Drupal treats it as a different object and refuses to overwrite one with the other. The same safeguard
applies to the whole site: system.site has a uuid key, and configuration can only be imported into a site that has the
same one. This is checked before an import begins, by an event subscriber in the System module
(SystemConfigSubscriber::onConfigImporterValidateSiteUUID).
The result is the first problem every team meets. Two developers who each run the installer on their own laptop get two different site UUIDs, and the second developer’s import of the first developer’s exported configuration fails with a message that the site UUID in the source storage does not match the target storage. The same happens when a staging site is created by installing Drupal fresh instead of cloning the production database.
The rule that prevents it: install the site once, export, and make every other environment from a copy of that site or from that exported configuration, never from a second independent installation. There are three ways to do it.
# 1. Best: create the other environments from a database dump of the first one.
ddev drush sql:dump --result-file=../backups/meridian.sql
ddev import-db --file=../backups/meridian.sql.gz # run on the other machine
# 2. Install a fresh site directly from the exported configuration. The sync
# directory is imported during the installation, so the UUIDs match.
ddev drush site:install minimal --existing-config --account-name=admin -y
# 3. Last resort on an existing empty site: copy the UUID from the sync files.
ddev drush config:set system.site uuid "$(grep '^uuid:' config/sync/system.site.yml | cut -d' ' -f2)" -y
ddev drush config:import -y
Source: Drush: site:install
--existing-config is documented as "Configuration from sync directory should be imported during installation". The
installation profile named in the command must be listed in the exported core.extension, and the third option should be
reserved for fresh sites that hold no content, because it only edits the number and does not reconcile the configuration
entities that were created by the second installation. When a fresh install already created entities such as the default
shortcut set, their UUIDs differ from the exported ones and the import can fail on them as well; deleting those entities
before importing is the usual remedy.
The Standard installation profile, in Drupal 11.4, no longer creates the Article and Page content types. Tutorials that
begin with "open the Article content type" and use a fresh install with site:install standard therefore have to
start by creating the content types, and a configuration export taken from a Standard install no longer contains
node.type.article and node.type.page unless a team creates them.
Configuration dependencies
A configuration entity cannot exist without the things it refers to. A field storage needs its entity type’s module, a
field needs its storage and its bundle, a view needs the module that provides its base table. Drupal records this in the
entity’s dependencies key, in four lists: module, theme, config and content. The entity-level rule is stated in
the documentation as: a configuration entity’s dependencies must be installed before the entity can be installed.
# config/sync/field.field.node.exhibition.field_hero_image.yml (excerpt)
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_hero_image
- media.type.image
- node.type.exhibition
module:
- media
id: node.exhibition.field_hero_image
field_name: field_hero_image
entity_type: node
bundle: exhibition
label: 'Hero image'
The dependency list is calculated, not written by hand. ConfigEntityBase::preSave() calls
ConfigEntityInterface::calculateDependencies(), and an implementation adds each dependency with addDependency(), usually
after calling the parent method, which resets the list and adds the providers of any plugins the entity uses. Developers who
extend a core base class and use the standard plugin APIs get the right dependencies for free. The enforced key lets a
module say that a configuration entity must be removed when that module is uninstalled; it is mostly used by install
profiles and recipes.
Dependencies matter for three operations:
-
Import. The importer sorts the changes so that an entity is created after those it depends on and deleted before them. A file that refers to a module that is not in
core.extensionfails validation. -
Uninstall. Uninstalling a module first lists the configuration entities that depend on it and deletes them with it. The confirmation page lists them, and Drush shows the same information when a module is uninstalled.
-
Export hygiene. A dependency on a content entity (
content:) refers to its UUID and is the reason some configuration, for example a block that embeds a specific block-content item, behaves badly when the content is missing on the target.
Exporting and importing with Drush
The everyday commands, with their documented aliases and options (Drush 13 and 14):
| Command | Alias | What it does |
|---|---|---|
|
|
Writes the whole active configuration to the sync directory. Options: |
|
|
Makes the active configuration equal to the sync directory: it creates, updates and deletes. Options: |
|
|
Lists the objects that differ between the active configuration and the sync directory (only in code, only in database, different). |
|
|
Read or change one object in the active configuration. |
# On the laptop: see what changed, then write it to the sync directory.
ddev drush config:status
ddev drush config:export --diff
ddev drush config:export -y
# On another environment, after pulling the commit: preview then apply.
drush config:import --diff
drush config:import -y
# Import only two files from a folder without deleting anything else.
drush config:import --partial --source=/var/tmp/meridian-fixes -y
Source: Drush: config:import
config:import is a mirror, not a merge: anything active that has no file in the sync directory is deleted. This is
why a person who creates a view directly on production, and a developer who deploys a branch that does not contain it, make
the view disappear. --partial is the exception and never deletes, but it also does not apply any configuration filter
(Config Split and Config Ignore, below, are skipped), so it is a tool for emergencies and not a deployment mode.
config:export rewrites every file in the sync directory and removes files for objects that no longer exist. Developers
should commit it with a Git workflow that makes a changed file visible in the diff; see
Git commits and pull requests.
The complete Drush reference, including the global options and the generic site-alias syntax, is in Drush.
The configuration UI
The administration interface offers the same operations under Configuration > Development > Configuration
synchronization (/admin/config/development/configuration) when the Configuration Manager module (config) is enabled.
That module is enabled by the Standard profile but is typically uninstalled on production, where the Drush commands are
used instead.
-
Synchronize (
/admin/config/development/configuration) shows the pending differences between the sync directory and the active configuration, lets an administrator view the line-by-line difference of a changed item and has the Import all button. -
Import > Full archive (
/admin/config/development/configuration/full/import) uploads atar.gzproduced by the full export; Export > Full archive (/admin/config/development/configuration/full/export) downloads it. The User Guide’s section 11.10 describes this flow: export the archive, upload it on the destination site, review the pending changes and click Import all. -
Import > Single item (
/admin/config/development/configuration/single/import) and Export > Single item (/admin/config/development/configuration/single/export) move one object. The exporter shows the YAML in a text area for the chosen type (for example View) and name; the importer takes pasted YAML, validates it and shows a confirmation page.
The User Guide’s section 11.9 walks through moving a single view from a development site to production in exactly this way: on the development site open Export > Single item, choose the configuration type View and the view, copy the text; on the production site open Import > Single item, choose View, paste, click Import and then Confirm. The guide notes that a content type with fields comprises several configuration items that must be transferred in the correct order, and suggests syncing the full configuration of a cloned site or, for bundles of related functionality, the contributed Features module.
The single-item importer is useful for experimenting and for copying an example from documentation. It is a poor
deployment tool for three reasons: the change is not in version control, the next full import from the sync directory
deletes it, and the pasted uuid and dependencies are not checked against the destination site as strictly as a full
import. In a team the right place to change configuration is the laptop, followed by an export and a commit.
How an import works internally
The import is performed by Drupal\Core\Config\ConfigImporter, which compares two storages with a StorageComparer: the
source (the sync directory) and the target (the active storage). The comparer produces a change list per collection
(create, update, delete and rename), and the importer runs it in steps, as a batch in the UI and in a loop in Drush:
-
Validation.
ConfigImporterdispatches the eventConfigEvents::IMPORT_VALIDATE; subscribers throwConfigImporterExceptionto stop the import. Core subscribers check the site UUID, that no module in the newcore.extensionis missing from the codebase, that the configuration of an uninstalled module is also removed and that the dependency graph is consistent. -
Extension changes. Modules and themes in the new
core.extensionare installed and uninstalled first, so that their configuration schema and entity types exist before the entities are processed. -
Configuration changes. Simple configuration and configuration entities are created, updated and deleted in dependency order. A configuration entity is saved through
ConfigEntityStorage::importCreate()andimportUpdate(), which means entity presave and postsave hooks run for each of them and a slowhook_entity_presave()implementation slows every deployment. -
Finalise.
ConfigEvents::IMPORTis dispatched so that modules can react, caches are cleared and the import is recorded.
A module can take part by subscribing to these events, for example to refuse an import that would disable the museum’s sync module while an import job is running:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Config\ConfigImporterEvent;
use Drupal\Core\State\StateInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Stops a configuration import while the collections sync is running.
*/
final class ConfigImportGuard implements EventSubscriberInterface {
public function __construct(private readonly StateInterface $state) {}
public static function getSubscribedEvents(): array {
return [ConfigEvents::IMPORT_VALIDATE => 'onImportValidate'];
}
public function onImportValidate(ConfigImporterEvent $event): void {
if ($this->state->get('meridian_collection.sync_running', FALSE)) {
$event->getConfigImporter()->logError('The collections sync is running; try again later.');
}
}
}
Source: ConfigImporter class
The subscriber is registered in meridian_collection.services.yml with the event_subscriber tag; see
Events and Subscribers. Note the use of the State API for a flag that is
specific to one environment, which is precisely what configuration is not for.
Configuration is also stored in collections. The default collection holds the base configuration; additional collections
hold per-language overrides (language.en, language.es) so that translated configuration, such as the Spanish
version of the site name, is exported as sync/language/es/system.site.yml and imported along with the rest. The
languages and the translation workflow are in Multilingual.
The deployment workflow
A reliable workflow has the same shape on every project, whatever the hosting. Configuration and code move together, as one commit, and the target environment applies them in a fixed order.
drush deploy
drush deploy is a single command that runs the steps in the right order. The Drush documentation lists them as
drush updatedb, drush config:import, drush cache:rebuild, drush deploy:hook and, in Drupal 11.2 and later,
drush cache:warm. The command takes no configuration of its own; to change how one step behaves, set Drush
configuration for that subcommand (for example for updatedb or config:import) in a drush.yml.
# Deployment script for staging and production (run from the project root).
composer install --no-dev --optimize-autoloader
vendor/bin/drush state:set system.maintenance_mode 1 --input-format=integer
vendor/bin/drush deploy -v -y
vendor/bin/drush state:set system.maintenance_mode 0 --input-format=integer
Source: Drush: deploy
The order is deliberate. Database updates run first because configuration to be imported may need columns and entity type definitions that only an update creates; configuration is imported second; the cache is rebuilt so that the new configuration is visible; and deploy hooks run last, when both schema and configuration are current. Drush documents three kinds of update function and their position in the sequence:
-
hook_update_N()is a Drupal update function for low-level changes, with the caution that the full API is not available. -
hook_post_update_NAME()is a Drupal post-update function and runs before the configuration is imported, as part of theupdatedbstep. -
hook_deploy_NAME()is a Drush function that runs after the configuration is imported, in thedeploy:hookstep.
A deploy hook is the right place for work that needs the new configuration to exist, such as filling a field that was
added by the same release. It lives in meridian_collection.deploy.php:
<?php
/**
* @file
* Deploy hooks for the Meridian collection module.
*/
use Drupal\node\Entity\Node;
/**
* Sets the new field_gallery_label on existing exhibitions.
*
* Runs after the configuration import that created the field.
*/
function meridian_collection_deploy_set_gallery_label(array &$sandbox): string {
$nids = \Drupal::entityQuery('node')
->accessCheck(FALSE)
->condition('type', 'exhibition')
->execute();
foreach (Node::loadMultiple($nids) as $node) {
$node->set('field_gallery_label', 'Main hall');
$node->save();
}
return 'Updated ' . count($nids) . ' exhibitions.';
}
Source: Drush: deploy and deploy hooks
The accessCheck() call is mandatory in entity queries. Deploy hooks that touch many entities should use the batch pattern
with $sandbox and return a message, as described in
Update API and Post Updates.
Run drush deploy after every code deployment, even when nothing seems to have changed in configuration. A deployment that
only runs drush config:import skips the database updates, and one that only runs drush updatedb skips the import; either
leaves the site half-updated. Pipelines, artefact builds and zero-downtime concerns are in
Deployment and CI/CD, and the DDEV and Docker setups in
Installation with Composer and DDEV and
Docker and Containers.
A continuous-integration job can fail the build when someone forgot to export, by checking that the active configuration equals the committed one:
# .github/workflows/config-check.yml (excerpt)
jobs:
config-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Install site from committed configuration
run: |
vendor/bin/drush site:install minimal --existing-config --yes \
--db-url="sqlite://localhost/sites/default/files/.ht.sqlite"
- name: Fail if the export differs from the committed files
run: |
vendor/bin/drush config:export --yes
git diff --exit-code config/sync
Source: Drush: site:install
This job installs from the sync directory and exports again; any difference means that the committed files are not self-consistent, for instance a missing dependency or a value changed by an update hook that was never exported.
Typical Meridian release
-
On the laptop, build the "Gallery" taxonomy and the Gallery label field in the interface, then run
ddev drush config:export -y. -
Review
git diff config/sync: a new vocabulary file, a field storage, a field, form and view displays and an editedcore.extensionare expected. Unexpected changes insystem.site,system.performanceorviews.settingsusually mean an override leaked into the export (see below). -
Commit code and configuration together and open a pull request.
-
After merge the pipeline builds an artefact and runs
drush deployon staging; editors check the result. -
The same artefact is promoted to production and
drush deployruns again.
Environment differences
Some configuration must not be the same everywhere: a development module such as Devel or Webprofiler, a different search server, a different caching back end, an analytics key or a different "site name" banner. Drupal has four levels of tools for that, from the least to the most invasive.
Overrides in settings.php
The simplest tool is the $config array in settings.php, which overrides configuration values at read time without
changing the stored configuration.
<?php
// settings.php (staging)
$config['system.site']['name'] = 'Meridian Museum (staging)';
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.logging']['error_level'] = 'verbose';
// Secrets come from the environment and never from exported YAML.
$config['meridian_collection.settings']['api_key'] = getenv('MERIDIAN_COLLECTIONS_API_KEY');
Source: Configuration override system
The behaviour has several consequences:
-
The override is applied when configuration is read through the config factory with
get(), and the original value is available withgetOriginal('name', FALSE). A configuration object loaded withgetEditable()is always free of overrides, so saving it never writes an override into the database. -
Overrides do not appear in the administration interface, which shows the stored value, so an administrator who changes the site name on staging sees no effect. Check them with
drush config:get system.performance --include-overridden, or use a contributed module such as Config Override Warn to flag them. -
Overrides are not exported. The exported value is the stored one, which lets staging use a value that the repository does not hold.
-
Overrides apply to values that already exist. They are not a way to enable a module or to create an entity.
The override system has three layers, in order of priority: settings.php wins over overrides provided by modules, which win over
language overrides. A module provides its own overrides by tagging a service with config.factory.override and
implementing ConfigFactoryOverrideInterface::loadOverrides(), which returns an array keyed by configuration name. Core
configuration forms do not display module overrides either.
Environment-specific values are better read from environment variables than from a per-environment copy of settings.php
that is kept in Git. The full treatment of settings.php, settings.local.php and environment detection is in
settings.php and Environments.
Config Split
The Config Split contributed module (project config_split) splits chosen configuration into separate directories and
merges it back during import. At the time of writing its latest stable release is 2.0.2 (12 February 2025), which supports
Drupal 8.8 and later up to 11, and the older 1.x line is for Drupal 9 and 10. In 2.x a split can also hold only the changed
keys of an object as a config patch, which is on by default. It is a contributed module and is not part of Drupal core.
Config Split defines splits as configuration entities named config_split.config_split.NAME. Each split has a folder, a
weight, a status and a list of what it holds:
-
A complete split removes the listed configuration from the main sync directory on export, so it only exists in the split folder. A development-only module such as
develis the typical case. -
A conditional split keeps the configuration in the main sync directory and, when the split is active, writes the environment’s different version to the split folder; on import the split’s version replaces the main one.
# config/sync/config_split.config_split.local.yml (shape; created in the UI at
# /admin/config/development/configuration/config-split)
id: local
label: 'Local development'
folder: ../config/local
module:
devel: 0
webprofiler: 0
theme: {}
complete_list: {}
partial_list: {}
weight: 0
status: false
The split is switched on per environment by overriding its status in settings.php, since the split settings are
"normal configuration" that "can be overwritten in settings.php", according to the module documentation. That is how a single
exported configuration enables the local split on laptops and not on production:
<?php
// settings.local.php, loaded only on developer machines
$config['config_split.config_split.local']['status'] = TRUE;
The documentation advises not to override the settings that decide what is written to the split folder (the complete and partial split lists), only the status, weight and folder. The 1.x series called the partial list the "graylist", which explains that term in older documentation.
The usual commands keep working: with a recent Drush the module documentation says the standard drush cex && drush cim
workflow honours the splits, and the dedicated commands drush config-split:export NAME and
drush config-split:import NAME act on a single split without touching the main sync directory. The Drupal native
config commands of other consoles are not recommended with splits. Because a split changes what core.extension
contains per environment, import order is: the main directory, then the active splits by weight.
Config Ignore
Config Ignore (project config_ignore) takes the opposite approach: instead of storing the different version somewhere, it
makes the importer skip chosen configuration, so that production keeps the values its administrators changed. Its latest
release is 8.x-3.4 (25 April 2026) for Drupal 8.8 and later up to 11, and its settings page is at
/admin/config/development/configuration/ignore.
-
Enter configuration object names, for example
system.siteormeridian_collection.settings, one per line. The module changes the configuration being imported so that it matches the active one, so the import finds no difference for those items. -
Simple mode ignores on import and export; intermediate mode has separate lists for import and export; advanced mode has six text areas.
-
To update an ignored item deliberately, use the single-item import.
-
$settings['config_ignore_deactivate'] = TRUE;insettings.phpswitches the module off, useful for a one-off full import. -
Never ignore
core.extension: doing so stops module installations from reaching the site. Environment-specific modules belong in Config Split. -
The project is marked minimally maintained, and the 3.x series uses the core API instead of Config Filter.
Source: Config Ignore project page
The two modules suit different cases. Config Split is explicit and reproducible: the environment’s variant is in Git and
reviewable. Config Ignore is permissive: the value is whatever the production administrators last saved, which is convenient
for a block of editorial text and dangerous for anything the code depends on, because the repository no longer
describes the site. For small differences prefer a settings.php override; for different module sets prefer Config Split;
use Config Ignore sparingly for settings that editors really own.
Locking production
Some teams make the production configuration read-only, so that nobody can change it from the interface and drift is impossible. The contributed Config Read-only module does this with a settings flag; it needs a plan for the occasional urgent change, and it is not part of Drupal core. Another common measure is not to install the Configuration Manager UI on production at all, uninstalling it after the first deployment and using Drush only.
Configuration versus content
Configuration management moves structure. It does not move content, and mixing the two is the second most common source of bad deployments. The User Guide’s deployment sections are explicit about it: moving a view moves a view and does not move the Vendor nodes that it lists.
| Configuration | Content | |
|---|---|---|
Examples |
Content types, fields, views, roles, text formats, image styles, enabled modules, block placements, menus' structure |
Nodes, taxonomy terms, media items, files, users, comments, menu links created by editors, block-content items |
Created by |
Developers and site builders |
Editors and visitors |
Identity |
Machine name, plus UUID for entities |
Numeric ID plus UUID, different per site |
Moves between environments by |
|
Database copy (down from production), migrations, recipes with content, or a default-content module |
Direction |
Laptop to production |
Production to laptop and staging |
The correct flow is therefore two-way: configuration flows up (laptop, staging, production) through Git, and content flows
down (production to staging to laptop) by copying and sanitising the database. A developer who needs the museum’s
exhibitions on a laptop copies the production database, runs drush sql:sanitize, then runs drush deploy to bring it up
to the branch’s configuration.
Content that must ship with the code, such as the "Visit", "Accessibility" and "About" pages, a starter set of taxonomy
terms or a default menu, is default content. Drupal has two routes: Recipes can carry content in their content
directory and import it when the recipe is applied (see Recipes), and the contributed
Default Content module exports and imports entities with their UUIDs. The meridian_events recipe applies the museum’s
event type, fields, view and permissions to a new site. Because recipes have no update path, a recipe is applied once; it
creates configuration that afterwards belongs to the site and is then exported to the sync directory like all other
configuration. The relationship of a recipe’s config actions to schema is in the recipes page.
Two traps follow from the content/configuration boundary. A configuration entity may depend on content (for example a block that embeds a block-content item, or a view with a hard-coded node ID), in which case the importer on the target site fails or produces an empty block because the content does not exist there. And content that is stored as configuration, such as a text format’s help text or a menu link kept in a configuration entity, reaches production only through an export, which is a reason to keep editorial text out of configuration. Workspaces are a separate concept that stage content changes, not configuration; see Workspaces.
Common errors and pitfalls
| Symptom | Cause and remedy |
|---|---|
The import stops with a message that the site UUID in the source storage does not match the target storage. |
The two sites were installed independently. Create the target from a database copy, install it with |
|
Import mirrors the sync directory. Create configuration only on the laptop and export it, or use Config Ignore for the few items that editors own. |
|
Different module versions, or an environment that was not imported before exporting. Run |
A change in |
A |
Edited YAML in |
Active configuration is in the database. Write an update function or import the changed file. |
Translators cannot see a setting. |
The schema type is |
The import reports a dependency or missing-module error. |
|
A module that was removed from the code is still listed in |
Uninstall the module (and export) before removing it with Composer. Removing a module’s code first leaves the site unable to boot until the module is restored. |
An API key shows up in a Git diff. |
A secret was saved into a configuration object and exported. Move it to an environment variable and a |
Cache problems after a deployment. |
|
Security
-
Secrets stay out of the sync directory. The exported files are committed, and they are read by everybody with access to the repository. Database passwords and the
hash_saltbelong insettings.phpread from the environment. API keys that a module stores in configuration should come from the Key module, an environment variable read in asettings.phpoverride or an equivalent mechanism. Scan the sync directory in CI for credentials. -
The sync directory is outside the web root. The files list modules, field names, permissions per role and internal URLs. Even if they are harmless individually, together they help an attacker.
-
Permissions in configuration are security-relevant.
user.role.*.ymlfiles list each role’s permissions. Review role changes in a pull request as carefully as code, and keep the Administer configuration and Synchronize configuration permissions with administrators only; a person who can import configuration can grant themselves any permission. See Users, Roles and Permissions. -
Uninstall the Configuration Manager UI on production if only Drush is used, which removes the browser-based path to full import and single-item import.
-
Treat imported YAML as code. A configuration import can enable modules, change text formats (an input format that allows unsafe HTML is a stored cross-site scripting risk), change trusted settings and replace views with PHP-evaluated fields. Only deploy configuration from reviewed commits.
-
Super-user and registration defaults are configuration too. Registration is set to administrators only on new installs since Drupal 11.1, and the
security.enable_super_usercontainer parameter can disable user 1’s bypass; verify that the exporteduser.settingsdoes not loosen them. See Security Hardening.
Version notes
-
The
$settings['config_sync_directory']setting has existed since Drupal 8.8; the earlier$config_directoriesarray is gone. -
The Standard profile does not create the Article and Page content types in 11.4, so exports from new sites no longer contain them.
-
drush deployincludescache:warmwhen Drupal is 11.2 or later; the other four steps are unchanged. -
Drush 13 serves Drupal 10.2 and 11, and Drush 14 serves Drupal 11.3 and 12. Install Drush per project with
composer require drush/drush. -
Config Split 2.0.x and Config Ignore 3.x support Drupal 11, and Config Split 1.x is limited to Drupal 9 and 10. Check each project page before upgrading to Drupal 12.
-
Drupal 10 ends on 2026-12-09 and 10.6 is its last minor; sites upgrading from 10.6 go to 11.3 or later. Drupal 12 refuses to upgrade from 11.2 or earlier, so a team that keeps its configuration current on 11.4 or 11.5 has the least work. In 12, query objects are no longer serialisable, which concerns any custom code that stores an entity query.
-
Several modules that were core in Drupal 10 left it in Drupal 11 (Statistics, Book, Forum, Tour and others) and more leave in 12. Install the contributed version of any removed module in use before upgrading and do not uninstall it, or its configuration is deleted with it and the next
config:importremoves it for good. See Removed Core Modules and Replacements and Preparing for Drupal 12. -
Recipes (in core since 10.3) apply configuration with config actions and have no update path: after being applied the site is ordinary Drupal.
Corrections to older material
Older books and tutorials describe the workflow correctly in outline but differ in details. Drupal 10 Development Cookbook
(Glaman and Quillen, 2023) teaches the Composer, DDEV and drush deploy workflow for Drupal 10.0 and Drush 12; since then
drush deploy has gained the cache:warm step (Drupal 11.2 and later), Drush 13 removed drush.services.yml in favour of
attributes and autowiring, and the Standard profile has stopped creating Article and Page (11.4). Drupal 10 Masterclass
(Bergstein, 2023) covers Git, Drush and DevOps for Drupal 10.0 and 10.1 but does not cover Config Split, Config Ignore, Workspaces
or Drupal CMS, which this page and Workspaces cover. Tutorials from the Drupal 8
era use $config_directories, the Features module for deployment and drush config-export with a hyphen, and none of
those is the current practice. The consolidated list is in
Where Older Books and Tutorials Are Outdated.
Meridian walkthrough: first deployment
The commands below take a new project from nothing to a repeatable pipeline. They assume the DDEV project from Installation with Composer and DDEV.
# 1. Install once, on one machine only (config_sync_directory is already set in settings.php).
ddev drush site:install minimal --account-name=admin --site-name='Meridian Museum' -y
ddev drush config:get system.site uuid # this UUID travels in config/sync/system.site.yml
# 2. Build the site in the UI or by recipe, then export and commit.
ddev drush config:export -y
git add config/sync && git commit -m "Initial Meridian configuration"
# 3. A second developer clones and installs from the exported configuration.
git clone https://example.test/meridian/site.git && cd site
ddev start && ddev composer install
ddev drush site:install minimal --existing-config --account-name=admin -y
# 4. From now on every environment is updated with one command.
ddev drush deploy -y
The second command in step 1 only displays the site UUID; the same number must be present in the sync files and on every environment. Install once on one machine, and create the others from its database or its exported configuration.