Provision content type fields
Goal
Provision a content type's fields with the correct storage architecture — each field's storage shape chosen by an ordered decision, recurring concerns collapsed to one concern-named shared storage, compound fields used only for genuine polymorphism, entity display wired, and any evolution of a populated compound field done through the safe managed path — so the data model is queryable, reusable, and free of fragmentation or over-engineering.
Opinion
Decide storage shape before creating any field. Run one ordered dispatch per field — polymorphic → compound; classification → vocabulary reference; shared concern → shared storage; entity-worthy collection → wrapper; independent entity → reference; else → plain field. Deciding ad hoc is what produces fragmentation and over-modeling. Source: guide drupal/entities/field-storage-decision.
Share storage by concern, and name it by concern. When a concern recurs across bundles of one entity type, emit ONE storage named for the concern (field_eyebrow), instanced across bundles — never field_hero_eyebrow + field_card_eyebrow. Source: guide drupal/entities/field-storage-configuration.
A custom compound only for genuine polymorphism. Reach for a compound (type: custom) when a value has ≥2 mutually-exclusive sub-shapes and no single core field expresses them. An optional single value is not polymorphic — it is a single-value field with required: false. Source: guide drupal/custom-field/overview.
Classification is a vocabulary, not a string. A controlled classification dimension is a taxonomy vocabulary referenced by an entity_reference, even when only one bundle uses it today — so terms, hierarchy, and term metadata stay governed. Source: guides drupal/entities/field-type-selection, drupal/entities/entity-reference-patterns.
Storage is per entity-type; reconcile cardinality to the max. Cardinality is a storage-level property with no per-bundle override, so a shared storage takes the maximum any bundle needs, and lower per-bundle limits are enforced at the widget. The same concern on two different entity types is legitimately two storages. Source: guide drupal/entities/field-storage-configuration.
Reference settings live on the instance, not the storage. handler, handler_settings, and target_bundles belong on the field instance; the storage carries only target_type. Source: guide drupal/entities/field-instance-configuration.
Wire display declaratively. Where UI Patterns 2 maps fields to component props/slots, use the declarative source mapping instead of Twig template overrides. Source: guides drupal/ui-patterns/field-formatters, drupal/ui-patterns/source-plugins.
Evolve a populated compound field through the managed path. Adding or removing a sub-column on a custom field that already holds content goes through the module's update service (a hook_update_N() calling custom_field.update_manager, or the cf-add-column command) — never hand-rolled ALTER TABLE, never delete-and-recreate. Source: guide drupal/custom-field/schema-updates.
What this recipe refuses
- N bundle-named storages for one semantic concern.
- A
customcompound for an optional single value (over-engineering), or N parallel "fill one" fields for data that is actually one polymorphic value. handler/handler_settingson a field storage (they fail schema validation there).- Altering a populated
customfield with hand-rolled SQLALTER TABLEor delete-and-recreate when the module's managed update service exists. - Suggesting
drush entity:updates/entup— it does not exist in Drupal 10/11 and never managed field-storage column schema.
Preconditions
- Drupal 10.4+ or 11.x; the
fieldmodule (core). custom_fieldenabled if any field in the model is polymorphic (needs acustomcompound).ui_patternsenabled if the display uses declarative field→component mapping.- The target content-type bundle exists, or is created as step 1 (see
drupal/entities/content-type-configuration). - A field/content model is supplied via the input contract.
- Config export is in use, so the provisioned model is deployable.
Input contract
Generic, source-agnostic, supplied by the caller (adapter skill, human operator, or orchestrator).
target:
entity_type: string # e.g. node
bundle: string # the content type
fields:
- name: string # concern-named machine name (snake_case)
concern: string # semantic role — used to detect sharing
shape: string # human description of the data shape
polymorphic: boolean # ≥2 mutually-exclusive sub-shapes? (default false)
classification: boolean # a controlled vocabulary? (default false)
shared_across_bundles: # other bundles carrying the same concern
- string
cardinality: integer # values allowed; -1 = unlimited (default 1)
required: boolean # instance-level (default false)
reference_target: string # entity type, when the field is a reference
display: # optional hints; wiring is deterministic from the model
form: string # widget preferences
view: string # formatter / UI Patterns source-mapping preferences
Sequence
If invoked in dry-run mode, perform all reads and derivations but emit a preview instead of writing config. Dry-run is required.
-
Validate preconditions and load the model. Confirm modules and the target bundle; if the bundle does not exist, create it. See
drupal/entities/content-type-configuration. -
Run the storage-decision dispatch per field. For each field, evaluate the ordered decision and record the verdict (compound / vocabulary reference / shared storage / wrapper / reference / plain field). See
drupal/entities/field-storage-decision. -
Collapse shared concerns. Group fields by
concern; where one concern recurs across bundles, resolve to a single concern-named storage instanced per bundle, and reconcile cardinality to the maximum any bundle needs. Seedrupal/entities/field-storage-configuration. -
Create storages and instances config-first, per the verdicts — reference
handler/handler_settingson the instance,target_typeon the storage. For classifications, ensure the vocabulary exists and reference it. Seedrupal/entities/field-instance-configuration,drupal/entities/entity-reference-patterns. -
Create compound fields only where the verdict was polymorphic (
type: custom), with the sub-field columns the shape needs — never for optional-single or "fill one" cases. Seedrupal/custom-field/overview. -
Wire entity display. Configure the form-display widgets and view-display formatters for the created fields; where UI Patterns 2 is used, declare the field→component source mapping instead of Twig overrides. See
drupal/entities/form-display-configuration,drupal/entities/view-display-configuration,drupal/ui-patterns/field-formatters,drupal/ui-patterns/source-plugins. -
If evolving an existing populated compound field, make the sub-column change through the managed update service (
hook_update_N()callingcustom_field.update_manager, orcf-add-column) — never hand-rolled SQL, never delete-and-recreate. Seedrupal/custom-field/schema-updates. -
Export configuration, rebuild caches, and emit a summary of what was created, skipped as a no-op, or surfaced as a conflict for operator review.
Data flow
input: target (entity_type, bundle)
fields[] (concern, shape, polymorphic, classification,
shared_across_bundles, cardinality, required, reference_target)
display (optional widget/formatter hints)
reads project state:
field.storage.<entity_type>.* / field.field.<entity_type>.<bundle>.*
existing bundles + taxonomy vocabularies
core.entity_form_display.* / core.entity_view_display.*
existing `custom` field column definitions (for evolution)
applies opinion (guardrails):
decide-shape-first · share-by-concern · compound-only-if-polymorphic ·
classification-is-a-vocabulary · cardinality-on-storage ·
reference-settings-on-instance · managed-compound-evolution
references atomic detail (guides):
drupal/entities/{ field-storage-decision, field-type-selection,
field-storage-configuration, field-instance-configuration,
entity-reference-patterns, content-type-configuration,
form-display-configuration, view-display-configuration }
drupal/custom-field/{ overview, schema-updates }
drupal/ui-patterns/{ field-formatters, source-plugins }
emits:
field.storage.<entity_type>.<name> (concern-named; cardinality = max)
field.field.<entity_type>.<bundle>.<name> (handler_settings here, not storage)
taxonomy.vocabulary.* (for classifications, if absent)
core.entity_form_display.* / core.entity_view_display.*
(compound evolution, when applicable, via custom_field.update_manager)
State-awareness contract
The recipe reads existing state before writing. For every emitted config object: absent → create; present and matching the resolved spec → skip, log no-op; present and differing → conflict, do not overwrite, request operator review.
Storage-level changes carry cross-bundle blast radius: adding a bundle instance to an existing shared storage is a create; reducing a shared storage's cardinality is a conflict (it affects every bundle already using it) and is never applied silently. Evolving a populated custom field always goes through the managed update service — a destructive delete-and-recreate is never chosen for a field that holds data.
Idempotent: running the recipe twice on identical input and identical project state produces no changes on the second run.
Verifier
After the recipe runs, verify:
- Each field's storage shape matches its decision verdict — polymorphic → a
type: customcompound; classification → anentity_referenceto a taxonomy vocabulary; independent entity →entity_reference; else a plain field. - Each recurring concern is exactly ONE concern-named storage instanced across its bundles — not N bundle-named storages — and its cardinality equals the maximum any bundle needs.
- Every
entity_referencefield keepshandler/handler_settingson the instance and onlytarget_typeon the storage. - No
customcompound exists where a single non-required core field would suffice (no over-engineering). - A sub-column added to a seeded
customfield via the managed update service leaves existing rows intact (zero data loss). - Every created field has a form-display widget and a view-display formatter wired.
The recipe ships no verifier script, but every check is agent-runnable. Checks 1–4 and 6 are config-assert (drush + config reads, no served site needed). Check 5 is self-fixture — it seeds a custom field with rows, runs custom_field.update_manager to add a column, and asserts the rows survive, cleaning up the fixture afterward (it does not assume an operator fixture). No check requires a served site, so none is fail-closed on its absence.
References
Atomic guides cited
| Guide | Used for |
|---|---|
drupal/entities/field-storage-decision |
The ordered per-field storage dispatch |
drupal/entities/field-type-selection |
Data-shape → field-type mapping |
drupal/entities/field-storage-configuration |
Shared-storage-by-concern, cardinality reconciliation |
drupal/entities/field-instance-configuration |
Reference settings on the instance, not storage |
drupal/entities/entity-reference-patterns |
Vocabulary and entity references |
drupal/entities/content-type-configuration |
Creating the bundle |
drupal/entities/form-display-configuration |
Form widgets |
drupal/entities/view-display-configuration |
View formatters |
drupal/custom-field/overview |
The polymorphic trigger and its over-engineering guard |
drupal/custom-field/schema-updates |
Managed compound-schema evolution on live data |
drupal/ui-patterns/field-formatters |
Declarative field→component formatter mapping |
drupal/ui-patterns/source-plugins |
UI Patterns source IDs for field data |