Defining bundle fields in code

Fields in Drupal 9 can be defined in code, or they can be defined in configuration. Both techniques have their uses and advantages. Typically code fields apply to all bundles of the entity type, as so-called base fields, while config fields apply only to a single bundle.

But there is a way to have code fields which apply only to specific bundles: these are called bundle fields. These are particularly useful for adding computed fields to specific bundles of an entity (more on computed fields in a future blog post).

The API for these is a bit clunky, as it wasn't finalised for Drupal 8, and it may in fact be completely changed in the future. If that causes you apprehension at implementing bundle fields in your code, it's worth mentioning that the last patch on that issue was in 2017: it's a complex problem without many applications, so accordingly doesn't get much attention.

Bundle fields need to be defined in two places: the field storage, and field definition itself. This is similar to how config fields work; it's actually the same as base fields, but the BaseFieldDefinition hides this from you as it does double duty. (There is an issue to remove the need to define the storage, but again, not much happening there.)

You also need a field definition class. The entity contrib module provides a BundleFieldDefinition class, but if you don't want to install that module just for a single class, copy-pasting that class to your custom module is fine too.

(There is a class in core FieldDefinition, which was added for this purpose, however, that requires a storage class to work alongside it, and the issue to provide that has not yet been fixed.)

Once you have that class, how you define the field depends on the entity you're adding it to.

Bundle fields on your own entity type

In your own entity type, you define the entity class. You can therefore implement the bundleFieldDefinitions() method in that class:

  // In MyEntity class:
  /**
   * {@inheritdoc}
   */
  public static function bundleFieldDefinitions(EntityTypeInterface $entity_type, $bundle, array $base_field_definitions) {
    $definitions = [];

    if ($bundle == 'mybundle') {
      $definitions['myfield'] = BundleFieldDefinition::create('entity_reference')
        // This is essential: the array key is NOT used as the field machine name,
        // so it MUST be specified here!
        ->setName('myfield')
        // These two are essential: they define the entity type and the bundle
        // that the field is on.
        ->setTargetEntityTypeId($entity_type->id())
        ->setTargetBundle($bundle)
        // Further define the field as you would with a base field.
        ->setLabel(t('Label'))
        ->setDescription(t('Description.'));
    }

    return $definitions;
  }

That defines the field. Because these are bundle fields, the storage must be defined separately in hook_entity_field_storage_info(). However, we can take advantage of the BundleFieldDefinition class doing double duty as a storage and a field, and piggy-back on the definition in the entity class:

/**
 * Implements hook_entity_field_storage_info().
 */
function mymodule_entity_field_storage_info(EntityTypeInterface $entity_type) {
  // Entity bundle fields need to declare their storage separately. However, we
  // can piggyback on the field definition itself, since the
  // BundleFieldDefinition class does double duty as field and storage
  // definition since it extends from BaseFieldDefinition.
  // We cheat on the parameters we pass in, as the entity's
  // bundleFieldDefinitions() method expects a list of base field definitions,
  // but we have none here to give. For our purposes, bundleFieldDefinitions()
  // does not need them.
  if ($entity_type->id() == 'myentity') {
    return MyEntity::bundleFieldDefinitions($entity_type, 'mybundle', []);
  }
}

The documentation for hook_entity_bundle_field_info() suggests you do it the other way round, and define the storage first, then derive the field from the storage, but doing it in the way shown above means that the code for your field is in your entity class where it's alongside the base field definition, and more easily discoverable.

Bundle fields on someone else's entity type

For an entity type you don't control, you could switch the entity class to a custom subclass and implement bundleFieldDefinitions() in that, but it's simpler to use hook_entity_bundle_field_info().

/**
 * Implements hook_entity_bundle_field_info().
 */
function mymodule_entity_bundle_field_info(EntityTypeInterface $entity_type, $bundle, array $base_field_definitions) {
  if ($entity_type->id() == 'myentity' && $bundle == 'mybundle') {
    $fields = [];
    $fields['myfield'] = BundleFieldDefinition::create('list_integer')
      // This is essential: the array key is NOT used as the field machine name,
      // so it MUST be specified here!
      ->setName('myfield')
      // These two are essential: they define the entity type and the bundle
      // that the field is on.
      ->setTargetEntityTypeId($entity_type->id())
      ->setTargetBundle($bundle)
      // Further define the field as you would with a base field.
      ->setLabel(t('Label'))
      ->setDescription(t('Description.'));

    return $fields;
  }
}

/**
 * Implements hook_entity_field_storage_info().
 */
function mymodule_entity_field_storage_info(EntityTypeInterface $entity_type) {
  if ($entity_type->id() == 'myentity') {
    return mymodule_entity_bundle_field_info($entity_type, 'mybundle', []);
  }
}

We use the same piggybacking trick here. In the case of altering someone else's entity type, it makes just as much sense to define the storage and derive the field, but following the same pattern as the custom entity keeps the two methods matching.

As you can see, this is an area of Drupal's entity system which is unfortunately incomplete, and liable to change in the future. Nonetheless, the usefulness of bundle fields means that it's worth using them. Just be sure to document your code so future developers (who might be you!) know that it's area of the code that may need maintainance when the API is updated. And keep an eye on the Drupal core change records!