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.
Default configuration in a module
A module ships its starting values as YAML files. The location decides when they are imported:
-
config/installis read when the module is installed. If a file’s dependencies are not met, or the object already exists, the install fails with aPreExistingConfigExceptionorUnmetDependenciesException. Use this directory for configuration the module cannot work without. -
config/optionalis 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/schemaholds 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.'
Source: Simple Configuration API
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 anImmutableConfig: a read-only object with overrides applied. Callingsave()ordelete()on it throws anImmutableConfigException. -
getEditable($name)returns aConfigobject 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)andlistAll($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
Source: Drush: config:get
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.
Source: Working with Configuration Forms
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:
-
settings.php: the global$configarray. -
Module overrides: services tagged
config.factory.overridethat implementConfigFactoryOverrideInterface. -
Language overrides: configuration objects named
language.config.LANGCODE.NAME, which hold translations.
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;
Source: Configuration override system
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';
}
}
Source: Configuration override system
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);
}
}
}
Source: Configuration override system
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'becomes1for an integer and0becomesFALSEfor 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 withtranslatable: true). A key declared asstringis invisible to translators. The fix is the type, not a flag on astring. -
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
createIfNotExistsandsimpleConfigUpdate, 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 themeridian_eventsrecipe 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_nameandweight. -
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: keyororderby: valuemakes the stored order deterministic, which keeps exports stable.orderby: valuedrops the keys, like PHP’ssort(). -
undefined, which Drupal assumes when nothing is declared, andignore, 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: ~
Source: Configuration schema/metadata
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();
}
}
Source: TypedConfigManagerInterface
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));
}
}
Source: PHPUnit in Drupal
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_prefixdecides the object name:meridian_collection.gallery_zone.main_hall. It is prepended with the module name, so the schema key ismeridian_collection.gallery_zone.*. Changing it later renames every stored object, so it must be settled before the first release. -
admin_permissionis 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 inmeridian_collection.permissions.yml, otherwise nobody except user 1 holds it. -
The
route_providerhandler builds the routes for the link templates, so norouting.ymlentries are needed. The official step-by-step page writes the four routes by hand with_entity_listand_entity_form; both approaches produce the same pages and the handler keeps the routes in step with the links. -
bundle_ofis set only on a configuration entity that is the bundle of a content entity (as a node type is of a node); the museum’sgallery_zoneis a plain configuration entity, not a bundle. See Custom Entity Types for the content-entity side. -
uri_callbackis 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.
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);
}
}
Source: Working with the entity API
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/installand 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()raisesImmutableConfigException. UsegetEditable()to write. -
Writing an overridden value into storage. Copying
$config->get('x')from an object loaded withget()into an editable object stores whateversettings.phpproduced. Read stored values from the editable object or withgetOriginal($key, FALSE). -
Forgetting
save()afterset()orclear(). Nothing is stored. Afterdelete()do not callsave(). -
A string that should be translatable declared as
string. Uselabelortext. The symptom is a key missing on the translation form. -
Schema key and object name that differ. An entity with
config_prefix: gallery_zonein modulemeridian_collectionneedsmeridian_collection.gallery_zone.*. Typing the wrong prefix gives anundefinedtype 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
FullyValidatablean optional value withoutnullable: truefails 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
_coreor uuid keys in module defaults. Let Drupal generate them. Copy-pasting an exported file with a UUID intoconfig/installmakes 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_nametype 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; injectconfig.factory.
Security
-
Access to configuration. The
administer site configurationpermission, and any customadminister ...permission, gives control of how the site behaves. Mark such permissions withrestrict access: trueso 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
Rangeonbatch_sizeprevents an administrator from starting a synchronisation of a million objects at once, and aChoiceorRegexkeeps 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
1is accepted inKioskOverrides. -
Translatable configuration is output. A value declared
labelortextis 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_targetonConfigFormBaseelements: Drupal 10.2.0. -
Config schema constraints and the
FullyValidatableconstraint: Drupal 10.3.0. -
Recipes config actions were renamed
createIfNotExistsandsimpleConfigUpdatein Drupal 10.3.1, and the oldensure_existsandsimple_config_updatenames are deprecated and slated for removal in Drupal 12. -
uri_callbackon 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_Nand 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
-
Add
config/install/meridian_collection.settings.ymland thegallery_zonedefault files, then the two schema entries from the schema section. -
Add the
CollectionSettingsservice and use it in the settings form, the queue worker and the Drush command. -
Add the
gallery_zoneentity class, the form, the list builder, the permission and the menu entries. -
Install the module and open
/admin/structure/gallery-zone; create "Planetarium floor". -
Run
ddev drush config:export -yand check thatmeridian_collection.gallery_zone.planetarium_floor.ymlappears in the sync directory with a UUID and adependenciessection. -
Run the kernel test of the schema; it must pass with strict schema checking switched on.
-
On staging, set the API URL in the environment’s
settings.phpand confirm withdrush 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.