Create a custom content and configuration entity

Last updated on
21 September 2016

This explains how to create a node-like entity in Drupal 8. That means that we'll create one configuration entity(FooType) which will serve as type or bundle for our second entity(Foo) which is a content entity and will represent the node itself. The content entity will also support revisions and translations(not yet tested).

2015-04-29 WARNING: This page is more than "needs updating" : most of its content is obsolete, since entity definitions are now provided using annotations-based plugins, the schema is no longer created manually using hook_schema(), permissions are now defined in <module>.permissions.yml, some global scope functions in the examples have been removed, and the <module>.routing.yml format has changed a bit too.

First we'll create our module's foo.info.yml file.
/modules/foo/foo.info.yml

name: Foo
description: Node-like entity example for Drupal 8.
type: module
core: 8.x
version: VERSION

Then we'll create our foo.install file which will contain the database schema and we'll automatically create a FooType entity named "generic" via hook_install() which will serve as bundle for our Foo content entity.

/modules/foo/foo.install

/**
 * Implements hook_schema().
 */
function foo_schema() {
  $schema = array();

  // In Drupal 8 we'll need 4 tables in order to support revisions and translations.
  // You can find more information about this here: https://drupal.org/node/1722906

  // The {entity} main table
  $schema['foo'] = array(
    'description' => 'Foo entity base table.',
    'fields' => array(
      'entity_id' => array(
        'description' => 'The primary entity identifier.',
        'type' => 'serial',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'uuid' => array(
        'description' => 'The unique entity identifier.',
        'type' => 'varchar',
        'length' => 60,
        'not null' => TRUE
      ),
      // Defaults to NULL in order to avoid a brief period of potential
      // deadlocks on the index.
      'revision_id' => array(
        'description' => 'The active revision identificator.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => FALSE,
        'unsigned' => TRUE,
        'default' => NULL
      ),
      'bundle' => array(
        'description' => 'The {foo_type}.type of this entity.',
        'type' => 'varchar',
        'length' => 48,
        'not null' => TRUE
      )
    ),
    'primary key' => array('entity_id'),
    'unique keys' => array(
      'uuid' => array('uuid'),
      'revision_id' => array('revision_id')
    )
  );

  // The {entity_revision} table
  $schema['foo_revision'] = array(
    'description' => 'Foo entity revision table.',
    'fields' => array(
      'entity_id' => array(
        'description' => 'The primary entity identifier.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'revision_id' => array(
        'description' => 'The revision identifier.',
        'type' => 'serial',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'langcode' => array(
        'description' => 'The language code the entity was created in.',
        'type' => 'varchar',
        'length' => 32,
        'not null' => TRUE
      ),
      'revision_uid' => array(
        'description' => 'The {users}.uid that created this version.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'revision_timestamp' => array(
        'description' => 'The Unix timestamp when the version was created.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE
      )
    ),
    'primary key' => array('revision_id')
  );

  // The {entity_field_data} table for Foo's own, non-FieldAPI, fields
  // holding the current/active revision's data.
  $schema['foo_field_data'] = array(
    'description' => 'Foo entity field data.',
    'fields' => array(
      'entity_id' => array(
        'description' => 'The primary entity identifier.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'revision_id' => array(
        'description' => 'The revision identifier.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE,
      ),
      'langcode' => array(
        'description' => 'The language code the entity was created in.',
        'type' => 'varchar',
        'length' => 32,
        'not null' => TRUE
      ),
      'default_langcode' => array(
        'description' => 'Boolean indicating if the entry holds values for the original language of the entity.',
        'type' => 'int',
        'size' => 'tiny',
        'not null' => TRUE
      ),
      'bundle' => array(
        'description' => 'The {foo_type}.type of this entity.',
        'type' => 'varchar',
        'length' => 48,
        'not null' => TRUE
      ),
      // Custom entity fields from now on
      'label' => array(
        'description' => 'The entity label.',
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE
      ),
      'uid' => array(
        'description' => 'The entity author identificator.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'published' => array(
        'description' => 'The status of this entity. 0 - disabled, 1 - enabled',
        'type' => 'int',
        'size' => 'tiny',
        'not null' => TRUE
      ),
      'created' => array(
        'description' => 'The UNIX timestamp when the entity was created.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'changed' => array(
        'description' => 'The UNIX timestamp when the entity was updated.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => TRUE,
        'unsigned' => TRUE
      )
    ),
    'primary key' => array('entity_id', 'langcode'),
    'indexes' => array(
      'foo_revision' => array('revision_id')
    )
  );

  // The {entity_field_revision} table for Foo's own, non-FieldAPI, fields
  // holding the data of all existing revisions.
  $schema['foo_field_revision'] = array(
    'description' => 'Foo entity field data.',
    'fields' => array(
      'entity_id' => array(
        'description' => 'The primary entity identifier.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'revision_id' => array(
        'description' => 'The revision identifier.',
        'type' => 'int',
        'not null' => TRUE,
        'unsigned' => TRUE,
      ),
      'langcode' => array(
        'description' => 'The language code the entity was created in.',
        'type' => 'varchar',
        'length' => 32,
        'not null' => TRUE
      ),
      'default_langcode' => array(
        'description' => 'Boolean indicating if the entry holds values for the original language of the entity.',
        'type' => 'int',
        'size' => 'tiny',
        'not null' => TRUE
      ),
      // Custom entity fields from now on
      'label' => array(
        'description' => 'The product label.',
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE
      ),
      'uid' => array(
        'description' => 'The entity author identificator.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'published' => array(
        'description' => 'The status of this entity. 0 - disabled, 1 - enabled',
        'type' => 'int',
        'size' => 'tiny',
        'not null' => TRUE
      ),
      'created' => array(
        'description' => 'The UNIX timestamp when the entity was created.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => TRUE,
        'unsigned' => TRUE
      ),
      'changed' => array(
        'description' => 'The UNIX timestamp when the entity was updated.',
        'type' => 'int',
        'size' => 'normal',
        'not null' => TRUE,
        'unsigned' => TRUE
      )
    ),
    'primary key' => array('revision_id', 'langcode')
  );

  return $schema;
}


/**
 * Implements hook_install().
 */
function foo_install() {
  // Creates the Generic FooType entity
  entity_create('foo_type', array(
    'label' => 'Generic Foo type',
    'type' => 'generic',
    'description' => 'A very informative text goes here.',
    'settings' => array('published' => 0)
  ))->save();
}

Note the indexes.

/modules/foo/foo.permissions.yml

administer entity:
  title: 'Administer Foo entity'
  description: 'Allows user to administer Foo entities.'
administer types:
  title: 'Administer Foo types'
  description: 'Allows user to administer Foo types.'

/modules/foo/foo.module


use Drupal\Core\Language\Language;
use Drupal\Core\Cache\CacheBackendInterface;


/**
 * Menu argument loader: Loads a Foo type by string.
 *
 * @param $name
 *   The machine name of a Foo type to load.
 *
 * @return \Drupal\foo\Entity\FooTypeInterface
 *   A Foo type object or NULL if $name does not exist.
 */
function foo_type_load($name) {
  return entity_load('foo_type', $name);
}


/**
 * Loads a Foo entity from the database.
 *
 * @param int $id
 *   The Foo entity ID.
 * @param bool $reset
 *   (optional) Whether to reset the static cache. Defaults to
 *   FALSE.
 *
 * @return \Drupal\foo\Entity\FooInterface|null
 *   A fully-populated Foo entity, or NULL if the entity is not found.
 */
function foo_load($id, $reset = FALSE) {
  return entity_load('foo', $id, $reset);
}


/**
 * Implements hook_entity_bundle_info().
 */
function foo_entity_bundle_info() {
  $bundles = array();
  // Bundles must provide a human readable name so we can create help and error
  // messages.
  foreach (foo_type_get_names() as $id => $label) {
    $bundles['foo'][$id]['label'] = $label;
  }
  return $bundles;
}


/**
 * Returns a list of available Foo type names.
 *
 * This list can include types that are queued for addition or deletion.
 *
 * @return array
 *   An array of Foo type labels, keyed by the Foo type name.
 */
function foo_type_get_names() {
  $cid = 'foo_type:names:' . language(Language::TYPE_INTERFACE)->id; // this name is completly custom
  if ($cache = cache()->get($cid)) {
    return $cache->data;
  }
  // Not using foo_type_get_types() or entity_load_multiple() here, to allow
  // this function being used in hook_entity_info() implementations.
  // @todo Consider to convert this into a generic config entity helper.
  $config_names = config_get_storage_names_with_prefix('foo.type.'); // the 'foo.type' is defined in Foo entiy definition
  $names = array();
  foreach ($config_names as $config_name) {
    $config = \Drupal::config($config_name);
    $names[$config->get('type')] = $config->get('name');
  }
  cache()->set($cid, $names, CacheBackendInterface::CACHE_PERMANENT, array(
    'foo_type' => array_keys($names),
    'foo_types' => TRUE,
  ));
  return $names;
}


/**
 * Returns a list of all the available foo types.
 *
 * This list can include types that are queued for addition or deletion.
 *
 * @return array
 *   An array of Foo type entities, keyed by ID.
 *
 * @see foo_type_load()
 */
function foo_type_get_types() {
  return entity_load_multiple('foo_type');
}

/modules/foo/foo.routing.yml

foo.overview:
  path: '/admin/structure/foo'
  defaults:
    _entity_list: 'foo'
    _title: 'Foo'
  requirements:
    _permission: 'administer foo'

foo.type_list:
  path: '/admin/structure/foo/types'
  defaults:
    _entity_list: 'foo_type'
    _title: 'Foo Type Configuration'
  requirements:
    _permission: 'administer foo'

foo.type_add:
  path: '/admin/structure/foo/types/add'
  defaults:
    _entity_form: foo_type.add
    _title: 'Add foo type'
  requirements:
    _permission: 'administer foo'

foo.type_edit:
  path: '/admin/structure/foo/types/{foo_type}'
  defaults:
    _entity_form: foo_type.edit
    _title: 'Edit foo type'
  requirements:
    _permission: 'administer foo'

foo.type_delete:
  path: '/admin/structure/foo/types/{foo_type}/delete'
  defaults:
    _entity_form: foo_type.delete
    _title: 'Delete foo type'
  requirements:
    _permission: 'administer foo'

foo.view:
  path: '/foo/{foo}'
  defaults:
    _content: '\Drupal\foo\Controller\FooController::view'
  _title_callback: '\Drupal\foo\Controller\FooController::getLabel'
  requirements:
    _entity_access: 'foo.view'
    foo: \d+

foo.edit:
  path: '/foo/{foo}/edit'
  defaults:
    _entity_form: foo.default
    _title: 'Edit foo'
  requirements:
    _permission: 'administer foo'
    foo: \d+

foo.delete:
  path: '/foo/{foo}/delete'
  defaults:
    _entity_form: foo.delete
    _title: 'Delete foo'
  requirements:
    _permission: 'administer foo'
    foo: \d+

foo.type_select:
  path: '/admin/structure/foo/add'
  _content: '\Drupal\foo\Controller\FooController::add'
  _title: 'Add foo'
  requirements:
    _permission: 'administer foo'

foo.add:
  path: '/admin/structure/foo/add/{foo_type}'
  defaults:
    _entity_form: foo_type.default
    _title: 'Add foo'
  requirements:
    _permission: 'administer foo'