 [ ← blog / drupal ](/) Apr 07, 2026  ·  \#drupal  ·  8 min read 

# 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](https://www.drupal.org/docs/drupal-apis/configuration-api/configuration-schemametadata#dynamic-types) 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:

```yaml
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()`.

```php
// 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

| Token | Resolves to |
|---|---|
| `%parent` | The parent element (switches data context) |
| `%key` | The 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`) |
| `%type` | The 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:

```yaml
# 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:

```yaml
# 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.*.*.*`:

```yaml
# 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.

`<strong>%type</strong>` **is only valid after** `<strong>%parent</strong>`**.** The expression `%type` alone throws a `\LogicException`. It must be `%parent.%type` or deeper.

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

`<strong>TypeResolver</strong>` **is** `<strong>@internal</strong>`**.** 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.

 

  ![](/themes/custom/mglaman/images/headshot.webp)Thanks for reading

I write about Drupal, PHP, and the quiet infrastructure behind large sites — caching, config, and testing. New posts land every week or two.

 [Subscribe via RSS →](/rss.xml) [About me →](/about-me) 

 



 

  [← Older

How Drupal's chained fast backend keeps APCu cache consistent across your web servers

 ](/blog/how-drupals-chained-fast-backend-keeps-apcu-cache-consistent-across-your-web-servers) [Newer →

phpstan-drupal 2.1.0: stricter defaults

 ](/blog/phpstan-drupal-210-stricter-defaults)