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.
- DDEV runs the site; the verifier's commands go through
ddev.
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 field machine name, with its field_ prefix
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
Each entry is one command, run from the project root after the recipe ran. It is split on spaces and never run through a shell. A non-zero exit fails the entry, whatever pass says. stdout empty reads standard output only; a non-interactive ddev drush keeps Drush's messages on standard error. A stopped DDEV project prints its start-up text on standard output, so start the site before the verify run. Entries that call .aida/provision-content-type-fields/verify.php run the script in ## Files. It prints one line per violation and exits non-zero when it printed any.
verifier: - id: storage-shapes kind: config-assert run: ddev drush php:script /var/www/html/.aida/provision-content-type-fields/verify.php -- storages {target.entity_type} {target.bundle} {fields:json} pass: stdout empty - id: shared-storage-instanced kind: config-assert run: ddev drush php:script /var/www/html/.aida/provision-content-type-fields/verify.php -- instances {target.entity_type} {target.bundle} {fields:json} pass: stdout empty - id: reference-settings-on-instance kind: config-assert run: ddev drush php:script /var/www/html/.aida/provision-content-type-fields/verify.php -- references {target.entity_type} {target.bundle} {fields:json} pass: stdout empty - id: displays-wired kind: config-assert run: ddev drush php:script /var/www/html/.aida/provision-content-type-fields/verify.php -- displays {target.entity_type} {target.bundle} {fields:json} pass: stdout empty - id: no-update-pending kind: config-assert run: ddev drush updatedb:status pass: stdout empty - id: active-equals-export kind: config-assert run: ddev drush config:status pass: stdout empty
What the entries do not prove, and where the proof is:
storage-shapeschecks each field's storage against its input flags:type: customif and only ifpolymorphic; a classification is anentity_referencetotaxonomy_term; areference_targetis anentity_referenceto that entity type. It cannot tell which core type a plain field should be, becauseshapeis free text. It also stands in for the over-engineering check: acustomstorage on a field not marked polymorphic is a violation.- One storage per concern is checked in the positive: each field's one storage is instanced on the target bundle and on every
shared_across_bundlesentry. No naming rule tells a bundle-named storage from a concern-named one. - Cardinality is checked as at least the declared value, or unlimited. The input carries one cardinality per field, so "the maximum any bundle needs" has nothing else to compare against.
shared-storage-instancedcomparesrequiredon the target bundle only. The input gives onerequiredper field, and the other bundles may carry their own.reference-settings-on-instancereads the raw config. A classification's instance must also list at least one vocabulary intarget_bundles, and each must exist.displays-wiredchecks thedefaultform and view displays of every bundle the field is on. Thedisplayhints are free text, so the widget and formatter chosen are not checked, nor is a UI Patterns source mapping.- The zero-data-loss check of a sub-column added to a populated
customfield is not run. It would test thecustom_fieldmodule's own update service, not this recipe's output, andcustom_field:add-columnis interactive only.no-update-pendingproves no update hook is pending on the site, so the hook that carries such a change ran. It is not scoped to this recipe: a pending update from any module fails it, and it passes on a run that evolved no compound field. active-equals-exportproves the export is current after Sequence step 8. The state-awareness contract makes a second apply a no-op on that state.- An absent or empty
fieldsprints a violation in every script entry. Flags absent from a field take the Input contract defaults.
Files
One script, which the consumer writes before the verifier runs and removes after it. Do not edit it or commit it. Drush runs it with Drupal booted and passes the words after -- in $extra.
```php .aida/provision-content-type-fields/verify.php