Skip to main content
[2026-04-07]

Dynamic type expressions in Drupal config schema

Drupal's config schema YAML supports dynamic expressions inside square brackets that resolve to values from the surrounding configuration data at runtime. Most developers have seen them — [%parent.type] in field formatter schema is a classic example — but few understand exactly how they work or when to use them.

I found a Todoist task from December 4th, 2024: \Drupal\Core\Config\TypedConfigManager::replaceVariable blog post. (Yeah, you do not want to see my "Overdue" list.) I have no memory of what I was working on that day or why I went deep on this. But past-me clearly thought it was worth documenting, so here we are. If you've ever stared at [%parent.type] in a schema file and just accepted it as magic — this one's for you.

This post covers the mechanics, the available expression tokens, and three patterns: resolving a schema type from a sibling value, keying sequence items as associative arrays, and resolving a schema type from within the element itself. The constraint system uses the same resolution logic without brackets. The Configuration API documentation covers dynamic types at a higher level — this post goes deeper into how resolution actually works.

Drupal 11 note: TypedConfigManager::replaceVariable() and TypedConfigManager::replaceName() were deprecated in Drupal 10.3 and removed in Drupal 11. The logic moved to Drupal\Core\Config\Schema\TypeResolver, which is marked @internal. The YAML syntax itself is unchanged — only the PHP implementation was refactored.

The mechanics

Before diving into the internals, here's the pattern in its simplest form. Given this schema:

field_formatter:
  type: mapping
  mapping:
    type:
      type: string
    settings:
      type: field.formatter.settings.[%parent.type]

When the stored config has type: text_default, the settings property resolves its schema type to field.formatter.settings.text_default. The expression [%parent.type] is replaced at runtime with the sibling value.

When Drupal processes a config schema definition, TypedConfigManager::buildDataDefinition() builds the schema definition for each element. Before looking up the schema type, it collects contextual data for the current element:

  • %parent — the parent typed data object
  • %key — the element's key within its parent
  • %type — the schema data type of the current element

It then calls TypeResolver::resolveDynamicTypeName(), which scans the type string for [expression] patterns and replaces each one with the resolved value from TypeResolver::resolveExpression().

// Drupal\Core\Config\Schema\TypeResolver

public static function resolveDynamicTypeName(string $name, mixed $data): string {
  if (preg_match_all("/\[(.*)\]/U", $name, $matches)) {
    $replace = [];
    foreach (array_combine($matches[0], $matches[1]) as $key => $value) {
      $replace[$key] = self::resolveExpression($value, $data);
    }
    return strtr($name, $replace);
  }
  return $name;
}

resolveExpression() walks a dot-separated path through the data. When it encounters %parent, it switches the data context to the parent element's values and adds %parent, %key, and %type into scope. This is recursive — %parent.%parent.%type resolves two levels up.

Expression tokens

TokenResolves to
%parentThe parent element (switches data context)
%keyThe current element's key in its parent — most useful in sequences, where keys are unknown to the schema and the key itself carries meaning (e.g. third_party_settings)
%typeThe schema data type of the current element — only valid after %parent

Tokens are combined with dots: %parent.targetEntityType resolves to the targetEntityType sibling value. %parent.%key resolves to the parent element's key within its own parent.

Dynamic type resolution

The most common use is constructing a schema type name from a sibling value. Field formatters use this to map each formatter plugin's type value to its settings schema:

# core/config/schema/core.entity.schema.yml

field_formatter:
  type: mapping
  mapping:
    type:
      type: string
      label: 'Plugin ID'
    settings:
      type: field.formatter.settings.[%parent.type]
      label: 'Settings'
    third_party_settings:
      type: sequence
      sequence:
        type: field.formatter.third_party.[%key]

When Drupal builds the schema for settings, %parent points to the field_formatter mapping, and %parent.type resolves to the value of the type key — e.g. text_default. The schema type becomes field.formatter.settings.text_default. Each plugin module can define field.formatter.settings.my_formatter_id: in its own schema file.

The sequence example uses %key. In a mapping, the schema defines the type of data stored by each named key. In a sequence, the keys are unknown to the schema — a single schema definition applies to every item regardless of what key it's stored under. third_party_settings stores data keyed by module name, and %key resolves to that key at schema build time. So a third-party settings entry from my_module resolves to field.formatter.third_party.my_module, letting each module define its own schema for its own data.

Resolving from within the element, not the parent

The expressions above use %parent to walk up the tree. You can also resolve a value from within the current element's own data by referencing a key directly — no %parent needed.

Block visibility conditions demonstrate this:

# core/modules/block/config/schema/block.schema.yml

block.block.*:
  type: config_entity
  mapping:
    plugin:
      type: string
      label: 'Plugin'
      constraints:
        PluginExists:
          manager: plugin.manager.block
          interface: Drupal\Core\Block\BlockPluginInterface
    settings:
      type: block.settings.[%parent.plugin]
    visibility:
      type: sequence
      label: 'Visibility Conditions'
      sequence:
        type: condition.plugin.[id]
        label: 'Visibility Condition'

settings uses [%parent.plugin] — the value of plugin is a sibling, so you need %parent to reach it.

visibility is a sequence. Each item in the sequence is itself a mapping that contains an id key. When Drupal builds the schema for a sequence item, the expression [id] resolves against that item's own data — no %parent required. A condition stored as { id: request_path, pages: /foo } produces a schema type of condition.plugin.request_path.

The distinction: use %parent.key when the value you need is in the parent (a sibling property). Use key directly when the value is inside the element being typed.

Dynamic constraint arguments

Constraints often need context that isn't contained in the constrained property itself. Without dynamic expressions, you'd need a separate schema definition for every entity type to validate that a bundle value is valid. The same resolution logic that drives type expressions is available to constraint option values — without the square brackets.

The canonical example is core.entity_view_display.*.*.*:

# core/config/schema/core.entity.schema.yml

core.entity_view_display.*.*.*:
  type: config_entity
  label: 'Entity display'
  mapping:
    targetEntityType:
      type: string
      label: 'Target entity type'
    bundle:
      type: string
      label: 'Bundle'
      constraints:
        EntityBundleExists:
          entityTypeId: '%parent.targetEntityType'

The bundle property carries an EntityBundleExists constraint. That constraint needs to know which entity type to check bundles against. Rather than hard-coding it, entityTypeId: '%parent.targetEntityType' tells the constraint to resolve targetEntityType from the parent mapping at validation time.

When TypedConfigManager::buildDataDefinition() processes the bundle element, it passes the current data and its parent context into resolveExpression(). The string %parent.targetEntityType walks up to the parent mapping and reads the targetEntityType value from the actual config data — e.g., node.

The result: a single schema definition validates the bundle value against the correct entity type for any core.entity_view_display.*.*.* config object, with no duplication. The same pattern applies to core.entity_form_display.*.*.*.

What you can't do

Expressions fail silently. If targetEntityType is missing from the config data, resolveExpression() returns the original expression string unchanged rather than throwing an error. The constraint then receives the literal string %parent.targetEntityType as its entityTypeId option. The constraint will still fail, but with a confusing error about an entity type that doesn't exist rather than a clear message about the missing value.

%type is only valid after %parent. The expression %type alone throws a \LogicException. It must be %parent.%type or deeper.

Square brackets are only for type values. Constraint option strings use the %parent.key syntax without brackets. Putting brackets in a constraint option value won't trigger dynamic resolution.

TypeResolver is @internal. Don't call TypeResolver::resolveExpression() directly from contrib or custom code. If you need to traverse the typed data tree in PHP — for example, inside a constraint validator — use the typed data API directly through the TypedDataInterface methods available on the validated object.

Summary

Dynamic expressions give config schema three capabilities: constructing a schema type from a sibling value ([%parent.type]), keying sequence items as associative arrays ([%key]), and constructing a schema type from within the element itself ([id]). Constraint option values use the same resolution logic without brackets, letting a single schema definition validate a property against context that lives elsewhere in the config object.

The practical takeaway: when a config property's valid values depend on a sibling property, reach for %parent.siblingKey in a constraint option. When a config property's schema type depends on a sibling value, use [%parent.siblingKey] in the type field. Core uses both patterns extensively — now you understand why they work.