Configuration API and Schema

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 API is stable across those versions; the few places where a feature depends on a minor version are marked. The page is written for module developers. Site builders and administrators who want the workflow of exporting and importing configuration between environments should read Configuration Management first, because this page does not repeat it.

The running example is the Meridian Museum, a fictitious regional science museum, and its custom module meridian_collection. In the steps of that module so far there is a settings form, a service and a custom content entity; this page adds what they all depend on: the configuration those pieces read, the schema that describes it, and a configuration entity type, gallery_zone, that lets curators define the physical zones of the museum in the administration interface. Classes are in the namespace Drupal\meridian_collection\, services are injected through the constructor, and hooks are written as #[Hook] attributes on classes in src/Hook, which needs Drupal 11.1 or later.

The two kinds of configuration

The Configuration API has two flavours, and the module author chooses between them when designing the data.

Simple configuration is a singleton: one object per name, stored under a name such as system.site or meridian_collection.settings. It suits the settings of a module, for which exactly one set of values exists on a site. It has no UUID, no dependencies list that the author maintains by hand and no entity API; it is read and written through the Drupal\Core\Config\Config object that the config factory returns.

Configuration entities are sets of objects of one type, each with an ID, a label and a UUID. Content types, fields, views, roles, image styles, text formats and block placements are configuration entities, and so is the gallery_zone type built later on this page. They are loaded, created and deleted through the Entity API (see Entity API), they declare their dependencies, and their list and edit forms are generated from handlers. A rule of thumb: if the site may need zero, one or many of the thing and administrators create them in the interface, use a configuration entity; if there is one fixed set of options, use simple configuration. The Configuration API overview also draws the line against other storage: values that must not move between environments (the last cron run, a lock, a cached token) belong in the State API, and data that belongs to visitors or editors belongs in content entities.

flowchart TD Q{What kind of data is it?} Q -->|Written by visitors or editors| C[Content entity<br/>stored in the database only] Q -->|Environment specific runtime value| S[State API] Q -->|Part of how the site is built| K{One fixed set of values?} K -->|Yes| SC[Simple configuration<br/>meridian_collection.settings] K -->|No, many named items| CE[Configuration entity<br/>meridian_collection.gallery_zone.main_hall]

Default configuration in a module

A module ships its starting values as YAML files. The location decides when they are imported:

  • config/install is read when the module is installed. If a file’s dependencies are not met, or the object already exists, the install fails with a PreExistingConfigException or UnmetDependenciesException. Use this directory for configuration the module cannot work without.

  • config/optional is read at install time too, but a file whose dependencies are not satisfied is silently skipped. Use it for configuration that depends on an optional module, for example a view that needs Views, or a display that needs Media. Skipped files are retried when the missing dependency is installed later.

  • config/schema holds the schema files, covered below. It is not imported.

File names follow the pattern module name, a dot, the object name; for a configuration entity the name has the entity type’s config_prefix in the middle, so the first gallery zone is meridian_collection.gallery_zone.main_hall.yml. The root of every file must be a mapping, not a list: otherwise the object cannot be described by a schema, and Drupal could not add its own bookkeeping keys on install.

# modules/custom/meridian_collection/config/install/meridian_collection.settings.yml
# Simple configuration with the defaults for a fresh install.
sync_enabled: false
api_base_url: 'https://collections.example.test/api/v2'
batch_size: 50
display_policy:
  show_on_display_only: true
  default_gallery: 'main-hall'
# modules/custom/meridian_collection/config/install/meridian_collection.gallery_zone.main_hall.yml
# A default configuration entity: the id is part of the file name.
langcode: en
status: true
dependencies: {}
id: main_hall
label: 'Main hall'
floor: 0
accessible: true
description: 'The entrance hall with the permanent geology gallery.'

When Drupal imports a default file it adds a _core key containing a default_config_hash. That hash lets the system tell whether a site has changed the shipped default, and the key is reserved: never write it by hand and never declare it in a schema. Two practical consequences follow. First, config/install is read only once, at install time, so editing the YAML in a module that is already installed changes nothing on that site. A change to an existing site is delivered with an update function, usually a post-update function, described in Update API and Post-Updates. Second, the shipped YAML is a starting point, not a promise: once the site exports its configuration, the exported copy in the sync directory is the version that deploys.

Reading configuration with config.factory

Everything starts at the config.factory service, whose interface is Drupal\Core\Config\ConfigFactoryInterface. It has three methods that matter day to day:

  • get($name) returns an ImmutableConfig: a read-only object with overrides applied. Calling save() or delete() on it throws an ImmutableConfigException.

  • getEditable($name) returns a Config object that can be changed and saved. It never contains overrides, so code that edits configuration cannot accidentally write an override value into storage.

  • loadMultiple(array $names) and listAll($prefix) fetch many objects or list the names that start with a prefix.

On a Config object, get('key') reads a value, with dots reaching nested keys (display_policy.default_gallery), and a parent key returns its subtree as an array. get() with no argument returns everything. set() changes a value and is chainable, but nothing is stored until save() is called. setData() replaces the whole object, so it must carry every key. clear('key') removes a nested key and needs a save(), while delete() removes the whole object and must not be followed by a save(), which would recreate it empty.

In a class, inject the factory through the constructor. The service below is the single place in the module that knows how settings are read; the controller, the queue worker and the Drush command of later steps depend on it instead of calling \Drupal::config().

# modules/custom/meridian_collection/meridian_collection.services.yml
services:
  Drupal\meridian_collection\CollectionSettings:
    autowire: true
  meridian_collection.settings_reader:
    alias: Drupal\meridian_collection\CollectionSettings
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Config\ConfigFactoryInterface;

/**
 * Typed access to the meridian_collection.settings configuration.
 */
final class CollectionSettings {

  public const CONFIG_NAME = 'meridian_collection.settings';

  public function __construct(
    private readonly ConfigFactoryInterface $configFactory,
  ) {}

  /**
   * Whether the nightly synchronisation is enabled (overrides applied).
   */
  public function isSyncEnabled(): bool {
    return (bool) $this->configFactory->get(self::CONFIG_NAME)->get('sync_enabled');
  }

  /**
   * The collections API base URL (overrides applied).
   */
  public function getApiBaseUrl(): string {
    return (string) $this->configFactory->get(self::CONFIG_NAME)->get('api_base_url');
  }

  /**
   * The number of objects fetched per queue item.
   */
  public function getBatchSize(): int {
    return (int) $this->configFactory->get(self::CONFIG_NAME)->get('batch_size');
  }

  /**
   * Stores a new batch size in the active configuration.
   *
   * getEditable() is used because it never contains overrides.
   */
  public function setBatchSize(int $size): void {
    $this->configFactory->getEditable(self::CONFIG_NAME)
      ->set('batch_size', $size)
      ->save();
  }

}

Procedural code, such as an .install file, a .module file or a test, may call \Drupal::config('name') and \Drupal::configFactory(); that is a shortcut for the same service. Inside a class, the static calls hide the dependency, make unit testing harder and are the pattern the official documentation advises against. A form that extends ConfigFormBase, or a controller that extends ControllerBase, already offers $this->config('name') and $this->configFactory(), which is acceptable because those base classes inject the factory themselves.

Command-line access to the same data is the quickest way to inspect an object:

# Stored value after all overrides.
ddev drush config:get meridian_collection.settings --include-overridden

# One key of one object, then change it (the change is in the active configuration only).
ddev drush config:get meridian_collection.settings batch_size
ddev drush config:set meridian_collection.settings batch_size 100 -y

# List every gallery zone configuration object.
ddev drush config:list --prefix=meridian_collection.gallery_zone

Configuration forms and #config_target

A settings form for simple configuration extends Drupal\Core\Form\ConfigFormBase, which wires saving, the status message and the typed-config manager. Since Drupal 10.2 an element can declare which configuration key it edits with the #config_target property. The base class then fills the default value from configuration, copies the submitted value back, and, most importantly, validates the value against the constraints of the schema, so the form needs no validateForm() for rules that the schema already states.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Settings form at /admin/config/content/meridian-collection.
 */
class CollectionSettingsForm extends ConfigFormBase {

  protected function getEditableConfigNames(): array {
    return ['meridian_collection.settings'];
  }

  public function getFormId(): string {
    return 'meridian_collection_settings';
  }

  public function buildForm(array $form, FormStateInterface $form_state): array {
    $form['sync_enabled'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Synchronise objects from the collections API'),
      '#config_target' => 'meridian_collection.settings:sync_enabled',
    ];
    $form['api_base_url'] = [
      '#type' => 'url',
      '#title' => $this->t('Collections API base URL'),
      '#config_target' => 'meridian_collection.settings:api_base_url',
    ];
    $form['batch_size'] = [
      '#type' => 'number',
      '#title' => $this->t('Objects per batch'),
      '#min' => 1,
      '#config_target' => 'meridian_collection.settings:batch_size',
    ];
    return parent::buildForm($form, $form_state);
  }

}

Two behaviours explain most surprises with config forms. A form always edits the stored value, never the overridden one: if settings.php overrides batch_size, the form still shows the stored value and the override keeps winning at runtime, which is why administrators sometimes report that "saving the form does nothing". And an element that has no counterpart in the schema cannot use #config_target, so the schema must be written before the form. The general form lifecycle, the ConfigTarget object for values that need a conversion callback, and the older style that copies values by hand in submitForm() are in Form API. Older books, such as Drupal 10 Development Cookbook (2023), show the manual style, which still works.

The override system

Reading a configuration object does not simply return what is stored. The config factory applies up to three layers of overrides, and the result is what get() returns. From highest precedence to lowest:

  1. settings.php: the global $config array.

  2. Module overrides: services tagged config.factory.override that implement ConfigFactoryOverrideInterface.

  3. Language overrides: configuration objects named language.config.LANGCODE.NAME, which hold translations.

flowchart LR ST[(Stored value<br/>active configuration)] --> LO[Language override<br/>language.config.es.NAME] LO --> MO[Module overrides<br/>config.factory.override services] MO --> SP[settings.php<br/>global config array] SP --> R[Value returned by get on ImmutableConfig] ST -. getEditable returns this only .-> E[Editable Config]

Overridden values are not shown on the administration forms, they are not exported by drush config:export, and they are not saved by getEditable(). That is the design: overrides make a value differ per request, per language or per environment without touching what is stored and deployed.

Overrides in settings.php

The cheapest override is a line in settings.php, usually in an environment-specific include (see settings.php and Environments). Nested keys are nested arrays. Use it for values that differ per environment, and take secrets from the environment so that they are never exported.

<?php

// web/sites/default/settings.local.php (staging and production include their own file).
$config['meridian_collection.settings']['api_base_url'] = getenv('MERIDIAN_COLLECTIONS_API_URL') ?: 'https://collections-staging.example.test/api/v2';
$config['meridian_collection.settings']['sync_enabled'] = TRUE;
$config['system.performance']['css']['preprocess'] = FALSE;

Module overrides

A module override is for rules that depend on the request: the current domain, the role of the visitor, the kiosk hardware. The class implements ConfigFactoryOverrideInterface and is registered with the config.factory.override tag; its priority orders it against other module overrides only, not against settings.php. The museum’s visitor kiosks request the site with a header, and the override below shows a shorter batch size and a different default gallery for that traffic. The class must not load the configuration it overrides through the factory’s get(), which would call the override again and recurse; it reads the value it needs from the request instead, and from getEditable() or the storage service if it ever needs the stored value.

# modules/custom/meridian_collection/meridian_collection.services.yml (addition)
services:
  Drupal\meridian_collection\Config\KioskOverrides:
    autowire: true
    tags:
      - { name: config.factory.override, priority: 5 }
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Config;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryOverrideInterface;
use Drupal\Core\Config\StorageInterface;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * Overrides collection settings for requests from the gallery kiosks.
 */
final class KioskOverrides implements ConfigFactoryOverrideInterface {

  public function __construct(
    private readonly RequestStack $requestStack,
  ) {}

  public function loadOverrides($names): array {
    $overrides = [];
    if (in_array('meridian_collection.settings', $names, TRUE) && $this->isKiosk()) {
      $overrides['meridian_collection.settings'] = [
        'display_policy' => ['show_on_display_only' => TRUE, 'default_gallery' => 'kiosk-lobby'],
      ];
    }
    return $overrides;
  }

  public function getCacheSuffix(): string {
    // Keeps the kiosk variant apart from the normal one in the static cache.
    return $this->isKiosk() ? 'meridian_kiosk' : 'meridian_default';
  }

  public function getCacheableMetadata($name): CacheableMetadata {
    // Anything rendered from this object varies by the header that decides the override.
    return (new CacheableMetadata())->addCacheContexts(['headers:X-Meridian-Kiosk']);
  }

  public function createConfigObject($name, $collection = StorageInterface::DEFAULT_COLLECTION) {
    return NULL;
  }

  private function isKiosk(): bool {
    return $this->requestStack->getCurrentRequest()?->headers->get('X-Meridian-Kiosk') === '1';
  }

}

The cacheability metadata is the part that is most often forgotten. If rendered output depends on an overridden value, the render cache must know what the override varied on; the metadata is returned from getCacheableMetadata(), and a config object’s own getCacheContexts() and getCacheTags() are bubbled when the object is added to a render array with CacheableMetadata::createFromObject(). Without it, the first request’s variant is cached and served to everybody. The caching rules themselves are in Render API and Cache Metadata. A module override is also a security surface: it runs on every request that reads a matching object, so it must be fast, must not perform network calls and must not read request data that an attacker controls without validating it.

Language overrides and the language of the read

Translations of configuration are language overrides. They are stored as ordinary configuration objects with the name language.config.es.meridian_collection.settings and travel through the sync directory like any other file. By default the override language is the interface language of the current request. Code that has to render configuration in somebody else’s language, for example an email in the preferred language of a recipient, switches the override language on the language manager and restores it afterwards. The language manager is injected as a service; the try and finally guarantee that the original language is restored even if the read fails.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\user\UserInterface;

/**
 * Reads a configuration value in the preferred language of a user.
 */
final class LocalizedSettings {

  public function __construct(
    private readonly ConfigFactoryInterface $configFactory,
    private readonly LanguageManagerInterface $languageManager,
  ) {}

  public function getDefaultGalleryFor(UserInterface $account): string {
    $original = $this->languageManager->getConfigOverrideLanguage();
    try {
      $this->languageManager->setConfigOverrideLanguage(
        $this->languageManager->getLanguage($account->getPreferredLangcode()),
      );
      return (string) $this->configFactory
        ->get('meridian_collection.settings')
        ->get('display_policy.default_gallery');
    }
    finally {
      $this->languageManager->setConfigOverrideLanguage($original);
    }
  }

}

The result of a read is easy to predict once the layers are clear. When a value is wrong on one environment, compare the overridden and stored values: drush config:get NAME --include-overridden shows the first, and plain drush config:get NAME shows the second. The Config Override Inspector contributed module lists every override on a site. The translation of configuration through the interface and the role of the Locale module are explained in Multilingual, and translating strings in code in Translation in Code.

Configuration schema

A configuration schema is a YAML description of the keys an object may contain and the type of each value. Files are named module_name.schema.yml and live in config/schema. Shared base types (config_object, config_entity, label, text, boolean, machine_name, and so on) are defined in core/config/schema/core.data_types.schema.yml. The format dates from Drupal 8 and is modelled on Kwalify.

Why schema matters

A schema is optional in the sense that an object without one still loads and saves, but a custom module without a schema loses four things.

  • Typecasting. When an object is saved, values are cast to the declared types: '1' becomes 1 for an integer and 0 becomes FALSE for a boolean. Without a schema a value silently keeps whatever type the form produced, and an export then differs from the previous one for no real reason.

  • Translation. The configuration translation forms list only the keys whose type is translatable (label, text, date_format, or any type with translatable: true). A key declared as string is invisible to translators. The fix is the type, not a flag on a string.

  • Validation. Constraints declared in the schema are checked when configuration is validated, as described below. This is what lets REST, JSON:API, recipes and the configuration forms reject bad values before they are stored.

  • Recipes and config actions. A recipe changes configuration through config actions such as createIfNotExists and simpleConfigUpdate, and those actions use the schema to know the shape of the data they are changing. A custom configuration entity type without a schema cannot be created by a recipe, which matters for the meridian_events recipe applied to a fresh site (see Recipes).

In addition, every PHPUnit test based on core’s test base classes enforces schema adherence by default: a test that installs or saves configuration without a matching schema fails, unless the test class sets protected $strictConfigSchema = FALSE;, which the documentation does not recommend.

Types, mappings and sequences

The vocabulary is small.

  • Basic types, backed by Typed Data: boolean, email, integer, float, string, uri.

  • Core types that add meaning: label (a short translatable string), plural_label, text (translatable, multi-line), uuid, path, date_format, color_hex, machine_name and weight.

  • mapping: a key-value structure in which each key is declared and typed.

  • sequence: a list whose keys are not known in advance, so only the type of the values is declared. A sequence is unsorted by default; orderby: key or orderby: value makes the stored order deterministic, which keeps exports stable. orderby: value drops the keys, like PHP’s sort().

  • undefined, which Drupal assumes when nothing is declared, and ignore, for data that cannot be typed. Both signal work to do.

Types can be named dynamically with square brackets, so that one key decides the type of another. [%key] is the element’s own key, [%parent.type] is a sibling key of the parent, and [table] reads a sibling value. Core uses it for plugin-based configuration such as image effects (image.effect.[%parent.id]), and a module whose settings depend on a plugin choice does the same.

The schema for the museum’s settings object, with the constraints that the next section explains, and for the gallery_zone entity type that is written later on this page:

# 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'
      constraints:
        Range:
          min: 1
          max: 500
    display_policy:
      type: mapping
      label: 'Display policy'
      mapping:
        show_on_display_only:
          type: boolean
          label: 'Show only objects on display'
        default_gallery:
          type: label
          label: 'Default gallery'
  constraints:
    FullyValidatable: ~

meridian_collection.gallery_zone.*:
  type: config_entity
  label: 'Gallery zone'
  mapping:
    id:
      type: machine_name
      label: 'ID'
    label:
      type: label
      label: 'Label'
    floor:
      type: integer
      label: 'Floor'
      constraints:
        Range:
          min: -2
          max: 10
    accessible:
      type: boolean
      label: 'Step-free access'
    description:
      type: text
      label: 'Description'
      nullable: true
  constraints:
    FullyValidatable: ~

Note that default_gallery uses label, not string: a gallery name shown to visitors is exactly the kind of value that the Spanish translation of the museum site must reach. The meridian_collection.gallery_zone.* name is the schema name for an entity type: it is the config_prefix plus a wildcard, and it must match the object names exactly. Dots separate the module name from the rest, and the wildcard stands for the entity ID.

Validation constraints and FullyValidatable

Schema types can carry constraints, which are Symfony validator constraint plugins from Drupal’s validation constraint plugin system. Common ones in configuration are Range (min, max), Length, Choice (choices), NotBlank, Regex, and Drupal’s own PluginExists, ConfigExists and UriHost. The machine_name core type already carries a Regex for lowercase letters, digits and underscores and a Length limit of 166 characters, which is why a configuration entity ID cannot be longer than that. Constraints placed on a base type apply to everything that uses the type; constraints placed on a key apply to that key only.

Validation became a core feature in Drupal 10.3. A top-level type opts in with the FullyValidatable constraint, as in the example above. A fully validatable type applies two stricter rules. First, no value may be NULL unless its key sets nullable: true, which is why the optional description of the gallery zone is marked that way. Second, every key of every mapping in the type must be present unless it sets requiredKey: false, and unknown keys are rejected. Because one wrong nested type makes the whole object unvalidatable, the constraint is meaningful only when every type inside the object declares its constraints, directly or through inheritance. Core uses it to make configuration changeable through JSON:API, REST and recipes with the same checks as the interface.

Validation is not automatic on every write. A plain $config->save() casts types and checks that the value shapes match the schema, but runs the declared constraints only where something validates the object: configuration forms using #config_target, the validation of imports and recipes, entity validation in the REST and JSON:API layers, and tests. Code that wants an explicit check asks the typed-config manager:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Config\TypedConfigManagerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;

/**
 * Validates a settings array against the meridian_collection.settings schema.
 */
final class SettingsValidator {

  public function __construct(
    private readonly TypedConfigManagerInterface $typedConfigManager,
  ) {}

  public function validate(array $values): ConstraintViolationListInterface {
    return $this->typedConfigManager
      ->createFromNameAndData('meridian_collection.settings', $values)
      ->validate();
  }

}

Each violation carries the property path (batch_size) and a message, so a Drush command or an API endpoint can report them to the caller. The test below installs the shipped defaults and asserts that they validate; because core’s kernel test base enforces the schema, it also fails if a key is missing from the schema file. It uses PHPUnit attributes (#[Group], #[RunTestsInSeparateProcesses]), which replace the annotations of older test classes in Drupal 11.2 and later; the broader testing set-up is in Automated Testing.

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
class SettingsSchemaTest extends KernelTestBase {

  protected static $modules = ['meridian_collection'];

  public function testDefaultSettingsValidate(): void {
    $this->installConfig(['meridian_collection']);
    $config = $this->config('meridian_collection.settings');
    $violations = $this->container->get('config.typed')
      ->createFromNameAndData('meridian_collection.settings', $config->get())
      ->validate();
    $this->assertCount(0, $violations, (string) $violations);
  }

  public function testBatchSizeOutOfRangeIsRejected(): void {
    $this->installConfig(['meridian_collection']);
    $data = $this->config('meridian_collection.settings')->get();
    $data['batch_size'] = 10000;
    $violations = $this->container->get('config.typed')
      ->createFromNameAndData('meridian_collection.settings', $data)
      ->validate();
    $this->assertGreaterThan(0, count($violations));
  }

}

A module can also adjust the schema of others through hook_config_schema_info_alter(). With OOP hooks the implementation is a method with #[Hook('config_schema_info_alter')] in src/Hook; this one adds a translatable flag the museum needs for a third-party setting:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Hooks that adjust configuration metadata.
 */
final class ConfigSchemaHooks {

  #[Hook('config_schema_info_alter')]
  public function schemaInfoAlter(array &$definitions): void {
    // Make the credit line of a contributed teaser setting translatable.
    if (isset($definitions['meridian_teaser.settings']['mapping']['credit_line'])) {
      $definitions['meridian_teaser.settings']['mapping']['credit_line']['type'] = 'label';
    }
  }

}

Finding schema problems before release is routine work. The Configuration Inspector contributed module reports missing or wrong schema for the whole site, and Drush ships a config:inspect command that compares stored data against schema from the terminal. Both read the same schema files that translation and validation use.

Writing a configuration entity type

A configuration entity type is a PHP class plus a handful of files: the entity class with the type’s declaration, its schema, a list builder, an add and edit form, a delete form, routes or link templates, and a menu link. The museum’s gallery_zone entity lets curators define zones such as "Main hall" or "Planetarium floor", with a floor number and a flag for step-free access; zones are structure, so they deploy with the rest of the configuration, and the collection objects of later steps can reference them.

Since Drupal 10.2 the type is declared with the #[ConfigEntityType] PHP attribute from Drupal\Core\Entity\Attribute\ConfigEntityType. Annotations (@ConfigEntityType) still work in Drupal 11 and are the form shown on the official step-by-step page, but they are on a deprecation path, and new code should use attributes. Core has also forked the part of the abandoned doctrine/annotations library that it needs (from 10.6), so existing annotations keep working while the library itself is not maintained.

The entity class and its interface

The config_export key lists, in order, the properties that are written to the YAML file. Properties that are not listed are not exported and not stored: forgetting one is the commonest reason a field "disappears" after a round trip through config:export. The listed names must match schema keys exactly. The entity_keys map the abstract keys (id, label, status, uuid) to those properties.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Config\Entity\ConfigEntityInterface;

/**
 * A physical zone of the museum, such as a hall or a floor.
 */
interface GalleryZoneInterface extends ConfigEntityInterface {

  public function getFloor(): int;

  public function isAccessible(): bool;

  public function getDescription(): ?string;

}
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\Attribute\ConfigEntityType;
use Drupal\Core\Entity\EntityDeleteForm;
use Drupal\Core\Entity\Routing\AdminHtmlRouteProvider;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\Form\GalleryZoneForm;
use Drupal\meridian_collection\GalleryZoneInterface;
use Drupal\meridian_collection\GalleryZoneListBuilder;

/**
 * Defines the gallery zone configuration entity.
 */
#[ConfigEntityType(
  id: 'gallery_zone',
  label: new TranslatableMarkup('Gallery zone'),
  label_collection: new TranslatableMarkup('Gallery zones'),
  label_singular: new TranslatableMarkup('gallery zone'),
  label_plural: new TranslatableMarkup('gallery zones'),
  config_prefix: 'gallery_zone',
  admin_permission: 'administer gallery zones',
  entity_keys: [
    'id' => 'id',
    'label' => 'label',
    'status' => 'status',
    'uuid' => 'uuid',
  ],
  handlers: [
    'list_builder' => GalleryZoneListBuilder::class,
    'form' => [
      'add' => GalleryZoneForm::class,
      'edit' => GalleryZoneForm::class,
      'delete' => EntityDeleteForm::class,
    ],
    'route_provider' => [
      'html' => AdminHtmlRouteProvider::class,
    ],
  ],
  links: [
    'collection' => '/admin/structure/gallery-zone',
    'add-form' => '/admin/structure/gallery-zone/add',
    'edit-form' => '/admin/structure/gallery-zone/{gallery_zone}',
    'delete-form' => '/admin/structure/gallery-zone/{gallery_zone}/delete',
  ],
  config_export: [
    'id',
    'label',
    'floor',
    'accessible',
    'description',
  ],
  constraints: [
    'FullyValidatable' => NULL,
  ],
)]
final class GalleryZone extends ConfigEntityBase implements GalleryZoneInterface {

  /**
   * The machine name; also the last part of the configuration object name.
   */
  protected string $id;

  protected string $label;

  protected int $floor = 0;

  protected bool $accessible = TRUE;

  protected ?string $description = NULL;

  public function getFloor(): int {
    return $this->floor;
  }

  public function isAccessible(): bool {
    return $this->accessible;
  }

  public function getDescription(): ?string {
    return $this->description;
  }

}

The important parts of the declaration, and what each one costs when wrong:

  • config_prefix decides the object name: meridian_collection.gallery_zone.main_hall. It is prepended with the module name, so the schema key is meridian_collection.gallery_zone.*. Changing it later renames every stored object, so it must be settled before the first release.

  • admin_permission is a shortcut for access control: anyone with that permission can do everything to the entity, and nobody else can. It is enough for structure managed by administrators; a finer model (for example, curators may edit zones but not delete them) needs an access control handler, as described in Access Control and Permissions in Code. The permission must be declared in meridian_collection.permissions.yml, otherwise nobody except user 1 holds it.

  • The route_provider handler builds the routes for the link templates, so no routing.yml entries are needed. The official step-by-step page writes the four routes by hand with _entity_list and _entity_form; both approaches produce the same pages and the handler keeps the routes in step with the links.

  • bundle_of is set only on a configuration entity that is the bundle of a content entity (as a node type is of a node); the museum’s gallery_zone is a plain configuration entity, not a bundle. See Custom Entity Types for the content-entity side.

  • uri_callback is deprecated in Drupal 11.4 and removed in Drupal 13. Define a link template and a route provider instead.

# modules/custom/meridian_collection/meridian_collection.permissions.yml
administer gallery zones:
  title: 'Administer gallery zones'
  description: 'Create, edit and delete the museum zones.'
  restrict access: true

The form, the list builder and the menu entries

The add and edit form extends Drupal\Core\Entity\EntityForm. The entity form provides $this->entity, and the form builder injects the entity type manager before the form is built, so no constructor is needed for this form. The machine_name element generates the ID from the label and checks, through the exists callback, that the ID is free.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Form;

use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;

/**
 * Add and edit form for gallery zones.
 */
class GalleryZoneForm extends EntityForm {

  public function form(array $form, FormStateInterface $form_state): array {
    $form = parent::form($form, $form_state);
    $zone = $this->entity;

    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Label'),
      '#maxlength' => 255,
      '#default_value' => $zone->label(),
      '#required' => TRUE,
    ];
    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $zone->id(),
      '#machine_name' => ['exists' => [$this, 'exists']],
      '#disabled' => !$zone->isNew(),
    ];
    $form['floor'] = [
      '#type' => 'number',
      '#title' => $this->t('Floor'),
      '#default_value' => $zone->get('floor'),
      '#min' => -2,
      '#max' => 10,
    ];
    $form['accessible'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Step-free access'),
      '#default_value' => $zone->get('accessible'),
    ];
    $form['description'] = [
      '#type' => 'textarea',
      '#title' => $this->t('Description'),
      '#default_value' => $zone->get('description'),
    ];
    return $form;
  }

  public function exists(string $id): bool {
    return (bool) $this->entityTypeManager->getStorage('gallery_zone')->load($id);
  }

  public function save(array $form, FormStateInterface $form_state): int {
    $status = parent::save($form, $form_state);
    $this->messenger()->addStatus($status === SAVED_NEW
      ? $this->t('Created the %label zone.', ['%label' => $this->entity->label()])
      : $this->t('Updated the %label zone.', ['%label' => $this->entity->label()]));
    $form_state->setRedirectUrl($this->entity->toUrl('collection'));
    return $status;
  }

}
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;

/**
 * Lists gallery zones at /admin/structure/gallery-zone.
 */
class GalleryZoneListBuilder extends ConfigEntityListBuilder {

  public function buildHeader(): array {
    $header['label'] = $this->t('Zone');
    $header['id'] = $this->t('Machine name');
    $header['floor'] = $this->t('Floor');
    return $header + parent::buildHeader();
  }

  public function buildRow(EntityInterface $entity): array {
    /** @var \Drupal\meridian_collection\GalleryZoneInterface $entity */
    $row['label'] = $entity->label();
    $row['id'] = $entity->id();
    $row['floor'] = $entity->getFloor();
    return $row + parent::buildRow($entity);
  }

}
# modules/custom/meridian_collection/meridian_collection.links.menu.yml
entity.gallery_zone.collection:
  title: 'Gallery zones'
  parent: system.admin_structure
  description: 'Define the halls and floors of the museum.'
  route_name: entity.gallery_zone.collection
# modules/custom/meridian_collection/meridian_collection.links.action.yml
entity.gallery_zone.add_form:
  route_name: entity.gallery_zone.add_form
  title: 'Add gallery zone'
  appears_on:
    - entity.gallery_zone.collection

The list builder generates the Edit, Delete and, because the entity has a status key, Enable and Disable operations. The delete form is core’s generic EntityDeleteForm, which asks for confirmation, deletes the entity and shows the standard message, so no custom delete form is needed. After enabling the module, the zones are listed at /admin/structure/gallery-zone, and a single object can be inspected at /admin/config/development/configuration/single/export when the Configuration Manager module is enabled.

flowchart LR A[Attribute on GalleryZone<br/>id, config_prefix, config_export] --> S[Schema<br/>meridian_collection.gallery_zone.*] A --> H[Handlers<br/>list builder, forms, route provider] H --> UI[Admin pages<br/>list, add, edit, delete] S --> V[Validation and translation] UI --> ST[(Active config<br/>meridian_collection.gallery_zone.NAME)] ST --> Y[Sync directory YAML<br/>via drush config:export]

Working with the entity in code

A configuration entity is created, loaded and saved like any entity; the difference is where it ends up. Saving writes an object named meridian_collection.gallery_zone.<id> to active storage, adds a UUID if absent, calculates dependencies and clears the relevant caches. The code below is what a post-update function or a Drush command would do to create the museum’s default zones; it uses the entity type manager through injection in classes, and \Drupal::entityTypeManager() is acceptable in .install or post-update procedural code only.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Entity\EntityTypeManagerInterface;

/**
 * Creates and reads gallery zones.
 */
final class GalleryZoneManager {

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  public function ensureZone(string $id, string $label, int $floor, bool $accessible = TRUE): GalleryZoneInterface {
    $storage = $this->entityTypeManager->getStorage('gallery_zone');
    $zone = $storage->load($id);
    if ($zone === NULL) {
      $zone = $storage->create([
        'id' => $id,
        'label' => $label,
        'floor' => $floor,
        'accessible' => $accessible,
      ]);
      $zone->save();
    }
    return $zone;
  }

  /**
   * Returns the zones that have step-free access, ordered by floor.
   *
   * @return \Drupal\meridian_collection\GalleryZoneInterface[]
   */
  public function accessibleZones(): array {
    $storage = $this->entityTypeManager->getStorage('gallery_zone');
    $ids = $storage->getQuery()
      ->accessCheck(TRUE)
      ->condition('accessible', TRUE)
      ->sort('floor')
      ->execute();
    return $storage->loadMultiple($ids);
  }

}

Entity queries on configuration entities read the stored YAML data, not database columns, so only properties in config_export can be queried, and the queries are far slower than on content entities for large sets. A configuration entity type is therefore meant for tens or hundreds of objects, not for the thousands of collection objects that the museum models as a content entity. Every entity query, content or configuration, has to call accessCheck() explicitly, and Drupal 12 stops query objects from being serialisable, so a query must never be stored in a property that is serialised into the cache or the session.

Dependencies

Configuration entities declare what they need through calculateDependencies(), which is called on every save and stores the result under dependencies: in the YAML. Dependencies are the basis of safe deletion and of the import order. Dependencies on the module that provides the entity type are added automatically; an entity that refers to another configuration entity (a field to its storage, a view display to a view mode) adds that with addDependency(). If the gallery_zone entity later referred to the gallery vocabulary, the override would be:

public function calculateDependencies(): static {
  parent::calculateDependencies();
  $vocabulary = $this->entityTypeManager()->getStorage('taxonomy_vocabulary')->load('gallery');
  if ($vocabulary !== NULL) {
    $this->addDependency('config', $vocabulary->getConfigDependencyName());
  }
  return $this;
}

The dependency graph and how it orders an import are explained with the sync workflow in Configuration Management. Computed dependencies need a schema as well: the dependencies key belongs to the config_entity base type and does not have to be declared again.

Common errors and pitfalls

  • Editing config/install and expecting an effect. The directory is read once, at install time. Use a post-update function to change existing sites, and the sync directory to deploy.

  • Saving an ImmutableConfig. get() returns a read-only object; save() raises ImmutableConfigException. Use getEditable() to write.

  • Writing an overridden value into storage. Copying $config->get('x') from an object loaded with get() into an editable object stores whatever settings.php produced. Read stored values from the editable object or with getOriginal($key, FALSE).

  • Forgetting save() after set() or clear(). Nothing is stored. After delete() do not call save().

  • A string that should be translatable declared as string. Use label or text. The symptom is a key missing on the translation form.

  • Schema key and object name that differ. An entity with config_prefix: gallery_zone in module meridian_collection needs meridian_collection.gallery_zone.*. Typing the wrong prefix gives an undefined type and a failing strict test.

  • A property missing from config_export. It is not stored, so the value is lost on the next load.

  • Missing nullability. Under FullyValidatable an optional value without nullable: true fails validation with "This value should not be null".

  • Root keys as a list. A configuration file whose top level is a list cannot be described by a mapping and is rejected by Drupal’s own tooling.

  • Hand-written _core or uuid keys in module defaults. Let Drupal generate them. Copy-pasting an exported file with a UUID into config/install makes the entity’s UUID the same on every site, which is intended for some shipped content types but surprising elsewhere.

  • Object names that are too long. A configuration name has a maximum length of 250 characters, and the machine_name type limits an ID to 166 characters. Long dynamic IDs, such as ones that include a UUID or a path, can exceed both.

  • Using \Drupal::config() in a class. It works but hides the dependency and complicates tests; inject config.factory.

Security

  • Access to configuration. The administer site configuration permission, and any custom administer ... permission, gives control of how the site behaves. Mark such permissions with restrict access: true so the permissions page warns administrators.

  • Secrets stay out of configuration. Configuration is exported, committed and copied between environments. An API key or password stored in an object ends up in Git. Keep secrets in the environment and read them with an override, a key module such as Key, or getenv(); the museum’s collections API credential is supplied by a Key module entity and the token itself comes from an environment variable. Hardening is covered in Security Hardening.

  • Validate input at the boundary. Constraints are the cheapest defence for values supplied through forms and APIs: a Range on batch_size prevents an administrator from starting a synchronisation of a million objects at once, and a Choice or Regex keeps free text out of keys that later become part of a file path or query.

  • Overrides run everywhere. A module override executes on every request that reads a matching object. Do not call external services from it and do not trust request data without checking it; only the exact header value 1 is accepted in KioskOverrides.

  • Translatable configuration is output. A value declared label or text is rendered to visitors. Render it through Twig’s autoescaping and never mark it with |raw.

Version notes

  • #[ConfigEntityType] attributes: Drupal 10.2 (plugin and entity type attributes). Annotations still work in 11.x.

  • #config_target on ConfigFormBase elements: Drupal 10.2.0.

  • Config schema constraints and the FullyValidatable constraint: Drupal 10.3.0.

  • Recipes config actions were renamed createIfNotExists and simpleConfigUpdate in Drupal 10.3.1, and the old ensure_exists and simple_config_update names are deprecated and slated for removal in Drupal 12.

  • uri_callback on entity types is deprecated in 11.4.0 and removed in 13.0.0.

  • OOP hooks with #[Hook]: Drupal 11.1; ordering and removal attributes in 11.2; themes in 11.3. hook_install, hook_update_N and a few others stay procedural.

  • PHPUnit attributes instead of annotations: Drupal 11.2; PHPUnit 10 support ended in 11.3.

  • Drupal 12 refuses to upgrade from 11.2 or earlier and makes query objects non-serialisable. Nothing in this page changes for the Configuration API itself, but custom modules should re-run the contributed Upgrade Status checks.

  • Drupal 10 ends on 2026-12-09; from 10.6 the upgrade target is 11.3 or later.

Corrections to older material

Drupal 10 Development Cookbook (Glaman and Quillen, 2023) teaches the Configuration API on Drupal 10.0 with the procedural \Drupal::config() entry point and a configuration entity type declared in an @ConfigEntityType annotation. Since 10.2 the declaration is a #[ConfigEntityType] attribute, injection is the standard way of reaching config.factory in a class, and configuration forms can use #config_target so that schema constraints validate the input. Its schema chapter predates configuration validation: since 10.3 a type can be declared FullyValidatable, which changes how nullable and requiredKey are written. Drupal 10 Masterclass (Bergstein, 2023) treats configuration mainly as a deployment topic and not as an API, so the schema and override layers on this page complement it. Tutorials from the Drupal 8 era use $config_directories, config_export as an optional key, and hook_install() to create default configuration, none of which is current practice. The consolidated list is in Where Older Books and Tutorials Are Outdated.

Meridian walkthrough

  1. Add config/install/meridian_collection.settings.yml and the gallery_zone default files, then the two schema entries from the schema section.

  2. Add the CollectionSettings service and use it in the settings form, the queue worker and the Drush command.

  3. Add the gallery_zone entity class, the form, the list builder, the permission and the menu entries.

  4. Install the module and open /admin/structure/gallery-zone; create "Planetarium floor".

  5. Run ddev drush config:export -y and check that meridian_collection.gallery_zone.planetarium_floor.yml appears in the sync directory with a UUID and a dependencies section.

  6. Run the kernel test of the schema; it must pass with strict schema checking switched on.

  7. On staging, set the API URL in the environment’s settings.php and confirm with drush config:get meridian_collection.settings api_base_url --include-overridden.

The next steps of the module build on this one: the custom content entity of Custom Entity Types can reference a gallery zone, and the Update API step in Update API and Post-Updates delivers new defaults to existing sites.