Compound items as entities¶
Every compound derives a content entity type of its own, cfi_<compound id>,
with one bundle of the same ID. Each sub-field is ordinary field.storage.*
and field.field.* configuration on that type, and every compound item is a
content entity of that type with its own UUID, distinct from the host's and
from every other item's. Values still live only in the host's flattened
columns (see database layout); the item is how the rest of
Drupal addresses them, not a second place they are stored.
This is what lets a module written for ordinary fields work on a sub-field without knowing about Compound Field:
- Configuration. The definition a sub-field list returns is the canonical
FieldConfigInterfaceentity, and its storage definition the canonicalFieldStorageConfigInterfaceentity. Both answerid(), implementThirdPartySettingsInterface, resolve throughFieldConfig::loadByName()andFieldStorageConfig::loadByName(), and are edited on core's own Field UI screens under/admin/structure/compound-field/<id>/fields/.... A setting another module stores there persists, exports, and is read back at runtime through the same objects. - Discovery.
getFieldDefinitions('cfi_<id>', 'cfi_<id>')andgetFieldMapByFieldType()list the sub-fields, so a constraint attached throughhook_entity_bundle_field_info_alter()reaches them and a referential entity query finds them. - Identity. A sub-field list answers
getEntity()with the item, and its definition'sgetTargetEntityTypeId()andgetTargetBundle()name a real, routed type and bundle. A widget that keys state on the entity UUID, or builds a Field UI link from$this->fieldDefinition->id(), gets a distinct key per item. Identity survives reorder; a copied or appended item gets a fresh UUID; a removed identity is never reused.
Two different compounds may reuse a sub-field name with incompatible types or settings; each lives on its own derived type and shares nothing.
Load, query and save¶
$storage = \Drupal::entityTypeManager()->getStorage('cfi_speaker');
$item = $storage->load($item_uuid);
$name = $item->get('name')->value;
$ids = $storage->getQuery()
->accessCheck(TRUE)
->condition('name', 'Ada Lovelace')
->execute();
$item->set('name', 'Grace Hopper');
$item->save();
A loaded item answers getHost(), getRootHost(), getHostFieldName() and
getHostDelta(). getHost() is the supported way from a compound item to its
host. It returns the immediate host translation, which is the enclosing item
for a nested compound. getRootHost() returns the entity at the top of the
chain, saved or not, or NULL when the chain ends at an unbound item. Do not
assume $items->getEntity() inside a compound is the host: it is the compound
item. A bound item's cache tags are its outermost host's.
From a host row, $node->get('field_speaker')->get(0)->get('cfi_item')->getValue()
returns the same item, so a consumer that follows typed-data entity references
reaches every sub-field. cfi_item is reserved as a sub-field name.
Saving it performs the same host write an ordinary field mutation does and
does not bypass the host's validation or lifecycle. A detached item, built
with no host, can be authored and validated but cannot persist until it is
bound to a host field value.
The supported query subset is bundle, identity and scalar equality, their
conjunctions, one OR group of field-equality conditions, and count over any
of those. A condition on a bare sub-field name means the field type's main
property (target_id for a reference, value for a scalar), as core's entity
query reads it. Any other shape is refused with an exception rather than
answered wrongly. Reading sub-field values from an already loaded host issues
no per-item query, and neither does materialising the item entity from them.
A background job should carry only the item's identity, its entity type and
UUID or the <host revision ID>:<item UUID> revision identity, and reload it
in the fresh process. The save is refused, with nothing written, when the item
has been removed, its host deleted, or the item changed since it was loaded.
Lifecycle, validation and access¶
Entity hooks and delegated field lifecycle methods run once per item per
operation, for insertion, update, removal and host deletion, whether the host
or the item's own storage started the save. A value changed in a hook
persists in that same save, without a recursive save or a duplicate side
effect, including a presave that reads one sibling's settings and writes
another. Load hooks (hook_entity_load, hook_entity_storage_load,
postLoad) fire once per item per host load, when the item is first
materialised, after host loading and cache handling. Reads through the item's
field lists see their effect; raw column reads, such as Views field handlers,
entity queries and SQL, see the stored value, as they do for an ordinary
field.
Validation and access are decided on the item. A violation maps to its outer item, sub-field and slot, so it lands on the right widget, and a field-level denial decided from a sub-field's configuration hides that sub-field while its siblings stay visible. Sub-widgets and formatters run unchanged inside an embedded form scope; see widgets inside a compound.
Boundaries¶
- Modules that pick behavior by field type. A module that walks the host's
fields and selects a plugin by type string sees one
compound_field:<id>field and no sub-fields. Diff, Feeds, GraphQL Compose and Metatag work this way, and only a plugin registered for the compound type can see inside it. The GraphQL Compose sub-module is such a plugin, for GraphQL Compose 2.x: it derives one field type plugin per compound, which resolves each row to its logical item, and exposes each compound's item type as a GraphQL Compose entity type, so the sub-fields resolve as any entity's fields do. See GraphQL Compose. - Form-level alters. The embedded form object exposes the logical item's
entity form ID for widget hooks to inspect. The scope does not build a
standalone form, so
hook_form_alter()andhook_form_FORM_ID_alter()fire once, for the host form, and never per item. Field Group and Conditional Fields attach that way and do not reach inside a compound. - Owner-based access. An item does not implement
EntityOwnerInterface. An access plugin that resolves "own content" against the field's entity takes its non-owner branch, and where that branch grants, as Field Permissions' Private setting does, the sub-field stays visible to every account. The same module's custom permissions, decided from the field's configuration, apply as configured. - Per-sub-field resource configuration. A host's JSON:API or REST resource
lists its own fields. A sub-field is configured, aliased or enhanced through
the derived type's resource (
cfi_<id>), never the host's. - Base fields with storage. A derived type stores nothing of its own: it
has no table, its base fields resolve onto the host row and its sub-fields
onto the host's columns. A module that installs a base field with storage on
every content entity type, as Field Encrypt does for its ciphertext, is
refused on
cfi_*types with aFieldStorageDefinitionUpdateForbiddenExceptionnaming the field and the type, rather than accepted and silently lost. The refusal fires where the installation happens: at the compound's creation, where the add form reports it and nothing is saved, or at the module's next storage update, which may be a later request than the form that stored its setting. Computed and custom-storage base fields need no table and are accepted. Such a module's remedy is to store against the host entity type.
Media Library¶
Core's MediaLibraryWidget runs unmodified on media sub-fields.
CompoundFieldMediaLibraryOpener authorises attaching a new item using its
host context, because core cannot determine that access from the derived
bundle alone.