Disclaimer: This article assumes you have a working knowledge of the following things:

  • Drupal’s Administration UI
  • PHP OOP syntax. Understanding namespaces will be helpful, but not 100% necessary to get the main points.
  • YAML syntax.

All of the examples used in this post are available in my custom_entities Github repo

This post covers:

  • Content Entities & Bundle Config Entities
  • Annotations
  • Entity Base Fields
  • Entity Forms
  • Routing & Menu items
  • List Builders
  • Generating Permissions
  • Access Control

About Entity API

Entities in Drupal 7+ are generic data models that are extended for specific functionality. For example, in Drupal 8 all of the following things are based on the generic Entity: Nodes, Comments, Users, and Blocks. Being entities means that each item shares the same foundation that offer common functionality, such as CRUD (create/read/update/delete) operations. A more Drupal-like example of shared functionality would be “fieldable”. Each of the items listed above can have custom fields added to them because they are all extensions of the foundational Drupal Entity.

The Entity API is how developers create, extend, and otherwise interact with entities in Drupal.

This post is an introduction to creating custom entities and bundles of those entities in Drupal 8. Let’s get started! The first thing you need to know is that Entities come in 2 main flavors. The Content Entity and the Configuration Entity.

Content Entity

Content Entities are stored in their own database table and are normally displayed on the site in some manner. All of the examples listed above are Content Entities, such as Nodes, Blocks, etc. That is not to say all Content Entities must be displayed, just that it is common to do so.

The most familiar example of a Content Entity is the Node, which are stored as rows within the node table in the database

Configuration Entity

Configuration Entities are based on the Config API, but used to store multiple sets of configuration data. Config Entity stores its data in the config database table, prefixed by the module name. A module can define additional prefixes for a configuration entity in its definition (annotation). This will be made more clear in the examples.

Simple description of Configuration data in general: Information about your site that is not content and changes infrequently, such as the name of your site.

Bundles

Bundles are a special variant of the Configuration Entity. They are Configuration Entities that enhance a content entity by providing mechanisms for having different Content Entity “Types”. A bundle configuration entity stores the different configurations for the various Content Entities, such as settings and custom fields.

The most notable example of Bundles in Drupal are Node Types (aka, Content Types). In the core Node module “Content Types” are setup as a configuration entity that stores the various fields and settings for an individual content type. While Nodes themselves are Content Entities that store the values for a single node.

Confused yet?

The mixture of terminology between “Bundle” vs “Type” are due to legacy Drupal versions. In Drupal 6 and lower, variants of a node were once called a “Node Type” and later renamed to “Content Type” for UX purposes. Drupal 7 introduced the concept of “Entities” and “Entity Types”, as well as the terminology “Bundle” to mean a sub-type of an Entity Type.

A simple breakdown of how this looks hierarchically:

  • Entity Type: Content Entity
    • Content Entity Type: Node
      • Bundle: Article
      • Bundle: Basic Page
    • Content Entity Type: User
      • Has no bundles

For an overview of different types of data within Drupal, see this documentation page.

Annotations

The Drupal 8 Entity API leverages the concept of Annotations to a great degree. Annotations look like common PHP docblock comments, but are parsed by the system to provide basic information/settings to the PHP class or method the annotations represent. Annotations in Drupal 8 are a product of using Symfony as the underlining framework. Drupal has extended the Symfony annotations system with its own custom annotations.

Annotation Syntax

@Function()
Function (or, “Object”) to execute with the contained parameters.
parameter = "value"
Parameter (or, “property”) that is passed into a function. Note that the parameter name itself is not surrounded by quotation marks.
array_parameter = { "some_key" = "some_value" }
Parameter that represents an array. Note the use of curly braces to define the array, and how keys in the array are surrounded by quotation marks.
Docblock
Annotations are contained within the syntax of a docblock, and appear immediately before a class or method.
Commas
Commas must separate parameters as well as items in an array.

Syntax usage example:

/**
 * @Function(
 *   parameter = "value",
 *   array_parameter = {
 *     "some_key" = "some_value",
 *     "another_key" = "another_value"
 *   }
 * )
 */

When viewed this way, it should become apparent where commas are necessary. You may also include commas after the final parameter or item in an array. I do this because it’s easier for me to remain consistent.

Entity Annotation Examples

Content Example:

/**
 * Defines the Most Simple entity.
 *
 * @ContentEntityType(
 *   id = "most_simple",
 *   label = @Translation("Most Simple"),
 *   base_table = "most_simple",
 *   entity_keys = {
 *     "id" = "id",
 *     "bundle" = "bundle",
 *   },
 *   fieldable = TRUE,
 *   admin_permission = "administer most_simple types",
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\Core\Entity\EntityListBuilder",
 *     "access" = "Drupal\Core\Entity\EntityAccessControlHandler",
 *     "views_data" = "Drupal\views\EntityViewsData",
 *     "form" = {
 *       "default" = "Drupal\Core\Entity\ContentEntityForm",
 *       "add" = "Drupal\Core\Entity\ContentEntityForm",
 *       "edit" = "Drupal\Core\Entity\ContentEntityForm",
 *       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   links = {
 *     "canonical" = "/most_simple/{most_simple}",
 *     "add-page" = "/most_simple/add",
 *     "add-form" = "/most_simple/add/{most_simple_type}",
 *     "edit-form" = "/most_simple/{most_simple}/edit",
 *     "delete-form" = "/most_simple/{most_simple}/delete",
 *     "collection" = "/admin/content/most_simples",
 *   },
 *   bundle_entity_type = "most_simple_type",
 *   field_ui_base_route = "entity.most_simple_type.edit_form",
 * )
 */

Okay, that’s a lot of moving parts. Let’s break it down a little bit.

New Annotations

@ContentEntityType()
Object that is instantiated with the contained parameters.
id
Machine-safe name for the Entity.
label
Human readable name for the Entity, passed through the @Translation() function so it can be translated by the UI.
base_table
Database table where this Entity is stored. This shodld be a custom table name, used only by your new Entity.
entity_keys
Array of aliases for base fields on the Entity. In this example, none of the field names are aliased. More about this later.
fieldable
Boolean value for whether or not this Entity can have Drupal fields attached/assigned to it through the Fields UI.
admin_permission
Permission used to allow adminstrative access to this entity’s settings / configuration.
handlers
Array map of common Entity operations to PHP classes that handle the functionality.
form
Array map of common Entity form names to PHP classes that handle the form operations.
route_provider
Array map of of route types to PHP classes. I’ve only personally dealt with the “html” route provider.
links
Array map of common Entity route names to the URIs where the functionality can be found.
bundle_entity_type
Id of the Config Entity that serves as bundles for this Content Entity.
field_ui_base_route
The name of the Entity route where the Fields UI shodld be attached.

Note:

It is not required to explicitly specify all of these annotation properties. Many of the ones shown here (especially “handlers”) are automatically generated when not provided. In my examples, I specify the default values of these properties in order to better understand what is expected and what happening behind the scenes.

Bundle Config Example:

/**
 * Defines the Most Simple Type entity. A configuration entity used to manage
 * bundles for the Most Simple entity.
 *
 * @ConfigEntityType(
 *   id = "most_simple_type",
 *   label = @Translation("Most Simple Type"),
 *   bundle_of = "most_simple",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid",
 *   },
 *   config_prefix = "most_simple_type",
 *   config_export = {
 *     "id",
 *     "label",
 *   },
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\Core\Entity\EntityListBuilder",
 *     "form" = {
 *       "default" = "Drupal\most_simple\Form\MostSimpleTypeEntityForm",
 *       "add" = "Drupal\most_simple\Form\MostSimpleTypeEntityForm",
 *       "edit" = "Drupal\most_simple\Form\MostSimpleTypeEntityForm",
 *       "delete" = "Drupal\Core\Entity\EntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   admin_permission = "administer most_simple types",
 *   links = {
 *     "canonical" = "/admin/structure/most_simple_type/{most_simple_type}",
 *     "add-form" = "/admin/structure/most_simple_type/add",
 *     "edit-form" = "/admin/structure/most_simple_type/{most_simple_type}/edit",
 *     "delete-form" = "/admin/structure/most_simple_type/{most_simple_type}/delete",
 *     "collection" = "/admin/structure/most_simple_type",
 *   }
 * )
 */

New Annotations

@ConfigEntityType()
Object that is instantiated with the provided properties.
bundle_of
Id for the Content Entity that this Config Entity enhances as a bundle.
config_prefix
Additional prefix for the values saved to the config database table.
config_export
Base fields that will be exported along with the entity.
form
Though not completely new, it’s worth pointing out that a custom Config Entity needs to provide its own Form handler for “default”, “add”, and “edit”. More on this later.

Note:

In many cases, it’s fair to consider annotations as a replacement for an older Drupal 7- hook. Looking at the examples, you can imagine how in previous versions of Drupal you might be required to leverage a hook within your custom module that returns an array with these settings.

Enough with the no-context examples, let’s make it work!

1. Most Simple Entity with Bundles

Quick confession: This is actually not the “most” simple version of a custom entity. You don’t necessarily need a to provide a custom permission. You could instead use an existing permission. Additionally, all of the annotations shown in the following example don’t need to be stated explicitly (especially handlers[form]), as they will automatically use the default core values when not present. I choose to specify each annotation to help me better understand what is going on.

Now, let’s create an extremely simple custom entity that is fieldable and has bundles. To do so, we’re going to need a few things:

The Plan

  • Custom permission – Alternatively, you could use an existing Drupal permission.
  • Content Entity – In this case, named “Most Simple”.
  • Config Schema – Details how the config entity is stored within the database.
  • Config Entity – In this case, named “Most Simple Type”.
  • Config Entity Form – Custom default/add/edit form for the Config Entity.

The Code

Permissions

This is pretty straight forward. Just create MODULENAME.permissions.yml file in the module and provide a new permission.

administer most_simple types:
  title: 'Administer MostSimpleEntity types'
  restrict access: true

Config Schema

Create the config/MODULENAME.schema.yml file within the module, and define the details for our Config Entity.

most_simple.most_simple_type.*:
  type: config_entity
  label: 'Most Simple Type settings'
  mapping:
    id:
      type: string
      label: 'Machine-readable name'
    uuid:
      type: stype
      label: 'UUID'
    label:
      type: label
      label: 'Label'

Content Entity

Besides annotations, you might be surprised how little PHP code it takes to create a very simple Content Entity. That is because of the magic that happens behind the scenes. Take a look.

<?php

namespace Drupal\most_simple\Entity;

use Drupal\Core\Entity\ContentEntityBase;

/**
 * Defines the Most Simple entity.
 *
 * @ContentEntityType(
 *   id = "most_simple",
 *   label = @Translation("Most Simple"),
 *   base_table = "most_simple",
 *   entity_keys = {
 *     "id" = "id",
 *     "bundle" = "bundle",
 *   },
 *   fieldable = TRUE,
 *   admin_permission = "administer most_simple types",
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\Core\Entity\EntityListBuilder",
 *     "access" = "Drupal\Core\Entity\EntityAccessControlHandler",
 *     "views_data" = "Drupal\views\EntityViewsData",
 *     "form" = {
 *       "default" = "Drupal\Core\Entity\ContentEntityForm",
 *       "add" = "Drupal\Core\Entity\ContentEntityForm",
 *       "edit" = "Drupal\Core\Entity\ContentEntityForm",
 *       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   links = {
 *     "canonical" = "/most_simple/{most_simple}",
 *     "add-page" = "/most_simple/add",
 *     "add-form" = "/most_simple/add/{most_simple_type}",
 *     "edit-form" = "/most_simple/{most_simple}/edit",
 *     "delete-form" = "/most_simple/{most_simple}/delete",
 *     "collection" = "/admin/content/most_simples",
 *   },
 *   bundle_entity_type = "most_simple_type",
 *   field_ui_base_route = "entity.most_simple_type.edit_form",
 * )
 */
class MostSimpleEntity extends ContentEntityBase {}

The Magic of ContentEntityBase

Aside from the annotation this file only has 3 lines of PHP. A namespace for this class, the import for the extending the ContentEntityBase class, and the custom class definition itself. But due to extending the ContentEntityBase, there is much more going on in the background when this class is instantiated.

Installation – On installation of the module containing this entity, the base_table will be created and the class’s “Base Fields” will define the columns for that table. But if you read closely, you’ll notice that we didn’t define anything in the annotation called “base fields”… so where are they coming from?

Base Field Definitions – The parent class ContentEntityBase has a static method named baseFieldDefinitions that is called at various times throughout Drupal core. Inside this method, it automatically looks for specific entity_keys and added them as base fields.

Entity Keys ContentEntityBase will automatically create field definitions for:

  • id
  • uuid
  • revision
  • langcode
  • bundle

You can also override the baseFieldDefinitions method in your own class to provide additional base fields. I’ll do this in a later example.

Config Entity

Creating a very simple Config Entity also requires very little PHP. Most of the work is done by Drupal parsing the annotation.

<?php

namespace Drupal\most_simple\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBundleBase;

/**
 * Defines the Most Simple Type entity. A configuration entity used to manage
 * bundles for the Most Simple entity.
 *
 * @ConfigEntityType(
 *   id = "most_simple_type",
 *   label = @Translation("Most Simple Type"),
 *   bundle_of = "most_simple",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid",
 *   },
 *   config_prefix = "most_simple_type",
 *   config_export = {
 *     "id",
 *     "label",
 *   },
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\Core\Entity\EntityListBuilder",
 *     "form" = {
 *       "default" = "Drupal\most_simple\Form\MostSimpleTypeEntityForm",
 *       "add" = "Drupal\most_simple\Form\MostSimpleTypeEntityForm",
 *       "edit" = "Drupal\most_simple\Form\MostSimpleTypeEntityForm",
 *       "delete" = "Drupal\Core\Entity\EntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   admin_permission = "administer most_simple types",
 *   links = {
 *     "canonical" = "/admin/structure/most_simple_type/{most_simple_type}",
 *     "add-form" = "/admin/structure/most_simple_type/add",
 *     "edit-form" = "/admin/structure/most_simple_type/{most_simple_type}/edit",
 *     "delete-form" = "/admin/structure/most_simple_type/{most_simple_type}/delete",
 *     "collection" = "/admin/structure/most_simple_type",
 *   }
 * )
 */
class MostSimpleTypeEntity extends ConfigEntityBundleBase {}

The main differences to note between this Config Entity and the previous Content Entity are the new annotation properties (described in the examples at the top of this post), and the new form handlers. For a Config Entity, you’ll need to provide a custom class for the form handlers (explained below).

The Magic of ConfigEntityBundleBase

Unlike the ContentEntityBase, the ConfigEntityBundleBase does not have a constructor that provides a lot of behind the scenes magic. Most of the methods in this base class are related to operations that are performed on the entity, such as the preSave() and postSave() methods.

Config Entity Form

As previously mentioned, we need to create our own form class for Config Entities. The reason for this is because ConfigEntityBundleBase does not magically turn our entity_keys into fields.

If you haven’t created a custom form for the config entity and you go to create an entity through the UI, there will be no fields in which to provide values for our entity_keys and the form will not save.

<?php

namespace Drupal\most_simple\Form;

use Drupal\Core\Entity\BundleEntityFormBase;
use Drupal\Core\Form\FormStateInterface;

class MostSimpleTypeEntityForm extends BundleEntityFormBase {

  /**
   * {@inheritdoc}
   */
  public function form(array $form, FormStateInterface $form_state) {
    $form = parent::form($form, $form_state);

    $entity_type = $this->entity;
    $content_entity_id = $entity_type->getEntityType()->getBundleOf();

    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Label'),
      '#maxlength' => 255,
      '#default_value' => $entity_type->label(),
      '#description' => $this->t("Label for the %content_entity_id entity type (bundle).", ['%content_entity_id' => $content_entity_id]),
      '#required' => TRUE,
    ];

    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $entity_type->id(),
      '#machine_name' => [
        'exists' => '\Drupal\mostSimple\Entity\MostSimpleTypeEntity::load',
      ],
      '#disabled' => !$entity_type->isNew(),
    ];

    return $this->protectBundleIdElement($form);
  }

  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface $form_state) {
    $entity_type = $this->entity;
    $status = $entity_type->save();
    $message_params = [
      '%label' => $entity_type->label(),
      '%content_entity_id' => $entity_type->getEntityType()->getBundleOf(),
    ];

    switch ($status) {
      case SAVED_NEW:
        drupal_set_message($this->t('Created the %label %content_entity_id entity type.', $message_params));
        break;

      default:
        drupal_set_message($this->t('Saved the %label %content_entity_id entity type.', $message_params));
    }

    $form_state->setRedirectUrl($entity_type->toUrl('collection'));
  }
}

In the custom Config Entity Form we define two methods; form() and save(). The form method simply provides fields for the entity_keys defined in the annotation, and the save method simply saves the entity and redirects the user with a message.

Results

That’s it! We now have a very simple Content Entity that has Bundles! Unfortunately, the user experience for managing these entities is terrible.

🙁 Problems:

  • No navigation. You must browse directly to the entity management URLs
  • Few messages telling us what happened. The only message we’ve created is when saving the Config Entity.
  • Few redirects dealing entities. The only redirect we’ve created is when saving the Config Entity
  • No practical information on the entity lists. If you were to navigate to the “collection” routes for these entities, all you would see is a list of operations for each entity. You can’t even tell which entity is which. We’ll fix this in the next example by adding ListBuilders to our module.

Luckily, most of these problems are pretty easy to fix…

2. Simple Entity with Bundles

Let’s solve the problems of the previous much-too-simple example.

Note: In this example the Content Entity has been renamed to “Simple” and the Config Entity has been renamed to “Simple Type”. This is because each example is actually its own module in the Github repo.

The Plan

  • Provide navigation for entity management.
  • Provide useful messages when managing entities.
  • Provide useful collections (lists) of entities.

The Code

Let’s creating some simple YAML files that will map the routes provided by the route_provider annotation to links within the Drupal UI.

Menu Links

This first of these is MODULENAME.links.menu.yml. This file adds links to Drupal’s core administrative menu system.

simple_entity_collection_menu:
  title: 'Simple entity list'
  route_name: entity.simple.collection
  description: 'List of Simple entities'
  parent: system.admin_content

simple_type_entity_collection_menu:
  title: 'SimpleEntity Types'
  route_name: entity.simple_type.collection
  description: 'List of Simple entity types (bundles)'
  parent: system.admin_structure
entity collection menu item
Entity collection menu item
entity bundle management menu item
Entity Bundle management menu item

Looks simple enough, but there is a mystery here. Where do all these route_names come from? The answer is in the Content Entity’s handlers[route_provider][html] annotation. Let’s look at how that works.

Route Provider & Links Annotations

The route_provider = {} works in conjunction with the links = {} annotations to dynamically create routes for your entities. For each of your links = {}, the route_provider creates a route named in this pattern:
entity.{{ your_entity_id }}.{{ machine_safe_link_key }}.

For example, consider the following abbreviated example and compare the links and their resulting route names:

/**
 * @ContentEntityType(
 *   id = "simple",
 *   handlers = {
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   links = {
 *     "canonical" = "/simple/{simple}",
 *     "add-page" = "/simple/add",
 *     "add-form" = "/simple/add/{simple_type}",
 *     "edit-form" = "/simple/{simple}/edit",
 *     "delete-form" = "/simple/{simple}/delete",
 *     "collection" = "/admin/content/simples",
 *   }
 * )
 */
Link Key Route Name Route URI
canonical entity.simple.canonical /simple/{simple}
add-page entity.simple.add_page /simple/add
add-form entity.simple.add_form /simple/add/{simple_type}
edit-form entity.simple.edit_form /simple/{simple}/edit
delete-form entity.simple.delete_form /simple/{simple}/delete
collection entity.simple.collection /admin/content/simples

The alternative to using a route_provider for your entity is that you would have to manually define all the routes yourself within a MODULENAME.routing.yml file. So as you can see, route providers are really helpful!

Moving on…

Tab Links on Entity (Tasks)

Task links give us the familiar View/Edit/Delete tabs at the top of an entity, just like you see on Nodes.

simple_entity_task_view:
  route_name: entity.simple.canonical
  base_route: entity.simple.canonical
  title: 'View'

simple_entity_task_edit:
  route_name: entity.simple.edit_form
  base_route: entity.simple.canonical
  title: 'Edit'

simple_entity_task_delete:
  route_name:  entity.simple.delete_form
  base_route:  entity.simple.canonical
  title: Delete
  weight: 10

After understanding the route_provider, this should look familiar. route_names are created by the route_provider, and base_routes are the route name these tasks should be attached to.

tabs on custom entity
Tabs added to custom entity.

“Add New” Entity Links (Actions)

Action links provide the helpful “Add New” buttons at the top of a list of entities. By now this should look pretty straight forward to you.

simple_entity_action_add:
  route_name: entity.simple.add_page
  title: 'Add Simple entity'
  appears_on:
    - entity.simple.collection

simple_type_entity_action_add:
  route_name: entity.simple_type.add_form
  title: 'Add Simple entity type (bundle)'
  appears_on:
    - entity.simple_type.collection
entity collection action
Entity collection action

Content Entity

The Content Entity has not changed much since the previous example, but there are some important differences. See if you can spot them.

<?php

namespace Drupal\simple\Entity;

use Drupal\Core\Entity\ContentEntityBase;

/**
 * Defines the Simple entity.
 *
 * @ContentEntityType(
 *   id = "simple",
 *   label = @Translation("Simple"),
 *   base_table = "simple",
 *   entity_keys = {
 *     "id" = "id",
 *     "bundle" = "bundle",
 *   },
 *   fieldable = TRUE,
 *   admin_permission = "administer simple types",
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\simple\SimpleListBuilder",
 *     "access" = "Drupal\Core\Entity\EntityAccessControlHandler",
 *     "views_data" = "Drupal\views\EntityViewsData",
 *     "form" = {
 *       "default" = "Drupal\simple\Form\SimpleEntityForm",
 *       "add" = "Drupal\simple\Form\SimpleEntityForm",
 *       "edit" = "Drupal\simple\Form\SimpleEntityForm",
 *       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   links = {
 *     "canonical" = "/simple/{simple}",
 *     "add-page" = "/simple/add",
 *     "add-form" = "/simple/add/{simple_type}",
 *     "edit-form" = "/simple/{simple}/edit",
 *     "delete-form" = "/simple/{simple}/delete",
 *     "collection" = "/admin/content/simples",
 *   },
 *   bundle_entity_type = "simple_type",
 *   field_ui_base_route = "entity.simple_type.edit_form",
 * )
 */
class SimpleEntity extends ContentEntityBase {}

That’s right, we have changed some of the handlers to use new custom classes that we will create. Specifically the list_builder and the default/add/edit forms. Good catch! Let’s see what those new classes look like.

Content Entity Form

The Content Entity’s new Form will look a lot like the previous Config Entity Form. All we need to do is extend the ContentEntityForm and override the save() method.

<?php

namespace Drupal\simple\Form;

use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Form\FormStateInterface;

/**
 * Class SimpleEntityForm
 */
class SimpleEntityForm extends ContentEntityForm {

  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface $form_state) {
    $entity = &$this->entity;
    $message_params = [
      '%entity_label' => $entity->id(),
      '%content_entity_label' => $entity->getEntityType()->getLabel()->render(),
      '%bundle_label' => $entity->bundle->entity->label(),
    ];

    $status = parent::save($form, $form_state);

    switch ($status) {
      case SAVED_NEW:
        drupal_set_message($this->t('Created the %bundle_label - %content_entity_label entity:  %entity_label.', $message_params ));
        break;

      default:
        drupal_set_message($this->t('Saved the %bundle_label - %content_entity_label entity:  %entity_label.', $message_params));
    }

    $content_entity_id = $entity->getEntityType()->id();
    $form_state->setRedirect("entity.{$content_entity_id}.canonical", [$content_entity_id => $entity->id()]);
  }
}

In the override for the save method, we provide some simple messages to inform the user what has just occurred, and a redirect that takes them back to viewing the entity they just created or updated. Easy peasy.

Content Entity List Builder

List Builders are used when displaying an Entity’s collection route. They should provide useful information about each entity, as well as common operations that can be performed on the entity.

To create a new List Builder, extend the EntityListBuilder class and override the buildHeader() and buildRow() methods. Return a keyed array of the header labels or row data respectively.

<?php

namespace Drupal\simple;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;

/**
 * Class SimpleTypeListBuilder
 */
class SimpleListBuilder extends EntityListBuilder {

  /**
   * {@inheritdoc}
   */
  public function buildHeader(){
    $header['id'] = $this->t('Linked Entity Id');
    $header['content_entity_label'] = $this->t('Content Entity Label');
    $header['content_entity_id'] = $this->t('Content Entity Id');
    $header['bundle_label'] = $this->t('Config Entity (Bundle) Label');
    $header['bundle_id'] = $this->t('Config Entity (Bundle) Id');

    return $header + parent::buildHeader();
  }

  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    $row['id'] = $entity->toLink($entity->id());
    $row['content_entity_label'] = $entity->getEntityType()->getLabel()->render();
    $row['content_entity_id'] = $entity->getEntityType()->id();
    $row['bundle_label'] = $entity->bundle->entity->label();
    $row['bundle_id'] = $entity->bundle();

    return $row + parent::buildRow($entity);
  }
}

The new List Builder for our Content Entity will show all the information we currently have about the entities, which at this point isn’t very much.

entity list builder
Entity List Builder

Config Entity

Like the Content Entity, the Config Entity (which manages our Bundles) has not changed very much from the previous examples. The only change is a new custom class for handlers[list_builder].

<?php

namespace Drupal\simple\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBundleBase;

/**
 * Defines the Simple Type entity. A configuration entity used to manage
 * bundles for the Simple entity.
 *
 * @ConfigEntityType(
 *   id = "simple_type",
 *   label = @Translation("Simple Type"),
 *   bundle_of = "simple",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid",
 *   },
 *   config_prefix = "simple_type",
 *   config_export = {
 *     "id",
 *     "label",
 *   },
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\simple\SimpleTypeListBuilder",
 *     "form" = {
 *       "default" = "Drupal\simple\Form\SimpleTypeEntityForm",
 *       "add" = "Drupal\simple\Form\SimpleTypeEntityForm",
 *       "edit" = "Drupal\simple\Form\SimpleTypeEntityForm",
 *       "delete" = "Drupal\Core\Entity\EntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   admin_permission = "administer simple types",
 *   links = {
 *     "canonical" = "/admin/structure/simple_type/{simple_type}",
 *     "add-form" = "/admin/structure/simple_type/add",
 *     "edit-form" = "/admin/structure/simple_type/{simple_type}/edit",
 *     "delete-form" = "/admin/structure/simple_type/{simple_type}/delete",
 *     "collection" = "/admin/structure/simple_type",
 *   }
 * )
 */
class SimpleTypeEntity extends ConfigEntityBundleBase {}

Config Entity List Builder

The List Builder for our Config Entity is even more simple than that of our Content Entity. The same principles apply to creating it, but in the case of the Config Entity we have very little data to show per row.

<?php

namespace Drupal\simple;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;

/**
 * Class SimpleTypeListBuilder
 */
class SimpleTypeListBuilder extends EntityListBuilder {

  /**
   * {@inheritdoc}
   */
  public function buildHeader(){
    $header['label'] = $this->t('Label');
    $header['id'] = $this->t('Machine name');

    return $header + parent::buildHeader();
  }

  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    $row['label'] = $entity->label();
    $row['id'] = $entity->id();

    return $row + parent::buildRow($entity);
  }
}
entity type list builder
Entity Type list builder

Config Entity Form

Since the purpose of this new version is to provide a better admin experience, I’ve modified the Config Entity Form to have an additional “Save and manage fields” button that when clicked will redirect the user to the “Manage Fields” tab for the entity.

<?php

namespace Drupal\simple\Form;

use Drupal\Core\Entity\BundleEntityFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\field_ui\FieldUI;

class SimpleTypeEntityForm extends BundleEntityFormBase {

  /**
   * {@inheritdoc}
   */
  public function form(array $form, FormStateInterface $form_state) {
    $form = parent::form($form, $form_state);

    $entity_type = $this->entity;
    $content_entity_id = $entity_type->getEntityType()->getBundleOf();

    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Label'),
      '#maxlength' => 255,
      '#default_value' => $entity_type->label(),
      '#description' => $this->t("Label for the %content_entity_id entity type (bundle).", ['%content_entity_id' => $content_entity_id]),
      '#required' => TRUE,
    ];

    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $entity_type->id(),
      '#machine_name' => [
        'exists' => '\Drupal\simple\Entity\SimpleTypeEntity::load',
      ],
      '#disabled' => !$entity_type->isNew(),
    ];

    return $this->protectBundleIdElement($form);
  }

  /**
   * {@inheritdoc}
   */
  protected function actions(array $form, FormStateInterface $form_state) {
    $actions = parent::actions($form, $form_state);

    if (\Drupal::moduleHandler()->moduleExists('field_ui') && $this->getEntity()->isNew()) {
      $actions['save_continue'] = $actions['submit'];
      $actions['save_continue']['#value'] = $this->t('Save and manage fields');
      $actions['save_continue']['#submit'][] = [$this, 'redirectToFieldUi'];
    }

    return $actions;
  }

  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface $form_state) {
    $entity_type = $this->entity;
    $status = $entity_type->save();
    $message_params = [
      '%label' => $entity_type->label(),
      '%content_entity_id' => $entity_type->getEntityType()->getBundleOf(),
    ];

    switch ($status) {
      case SAVED_NEW:
        drupal_set_message($this->t('Created the %label %content_entity_id entity type.', $message_params));
        break;

      default:
        drupal_set_message($this->t('Saved the %label %content_entity_id entity type.', $message_params));
    }

    $form_state->setRedirectUrl($entity_type->toUrl('collection'));
  }

  /**
   * Form submission handler to redirect to Manage fields page of Field UI.
   *
   * @param array $form
   * @param FormStateInterface $form_state
   */
  public function redirectToFieldUi(array $form, FormStateInterface $form_state) {
    $route_info = FieldUI::getOverviewRouteInfo($this->entity->getEntityType()->getBundleOf(), $this->entity->id());

    if ($form_state->getTriggeringElement()['#parents'][0] === 'save_continue' && $route_info) {
      $form_state->setRedirectUrl($route_info);
    }
  }
}

The actions() method is overriding that of the parent class and adding a new “Save and manage fields” button. That button has a #submit handler on it that will call this class’s custom redirectToFieldUi() method.

Results

That’s it! Improving our most_simple entity by creating a better admin experience was mostly about providing navigation, useful messages, and entity information on the collections route. Our resulting simple entity is not too shabby.

🤠 Problems: Not many! Sure, there is plenty of room for improving this example and making it much more practical, but as far as it goes we have addressed the major issues one might have when working with these entities. Huzzah!

3. Practical Entity with Bundles

Time to take these Entities to the next level!

Note: In this example the Content Entity has been renamed to “Practical” and the Config Entity has been renamed to “Practical Type”. This is because each example is actually its own module in the Github repo.

The Plan

  • Per-bundle permissions and access control.
  • Class Interfaces to follow best practices.
  • Additional base fields for the Content Entity, making it more like Nodes.
    • Name – serves as the entity’s “label”.
    • Uid – serves as the “Owner” of the entity.
    • Created – date entity was created.
    • Changed – date the entity was last updated.
  • Description field for the Config Entity.
  • Better ListBuilders

The Code

Permissions

A minor update to the permissions yaml file allows us to specify a PHP callable that will return an array of dynamically generated permissions.

administer practical types:
  title: 'Administer PracticalEntity types'
  restrict access: true

permission_callbacks:
  - '\Drupal\practical\PracticalPermissionsGenerator::practicalTypePermissions'

Permissions Generator

Next we need to create the callback we just told the permissions yaml file about, and we’ll do so with a new class. Note, this is the only class in all of the examples that doesn’t extend some core Drupal class. What goes in here is all up to you!

<?php

namespace Drupal\practical;

use Drupal\practical\Entity\PracticalTypeEntity;
use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Class PracticalPermissionsGenerator
 */
class PracticalPermissionsGenerator {

  use StringTranslationTrait;

  /**
   * Loop through all PracticalTypeEntity and build an array of permissions.
   *
   * @return array
   */
  public function practicalTypePermissions() {
    $perms = [];
    foreach (PracticalTypeEntity::loadMultiple() as $entity_type) {
      $perms += $this->buildPermissions($entity_type);
    }
    return $perms;
  }

  /**
   * Create the permissions desired for an individual entity type.
   *
   * @param PracticalTypeEntity $entity_type
   *
   * @return array
   */
  protected function buildPermissions(PracticalTypeEntity $entity_type) {
    $type_id = $entity_type->id();
    $bundle_of = $entity_type->getEntityType()->getBundleOf();
    $type_params = [
      '%type_name' => $entity_type->label(),
      '%bundle_of' => $bundle_of,
    ];

    return [
      "create $bundle_of $type_id" => [
        'title' => $this->t('%type_name: Create new %bundle_of', $type_params),
      ],
      "view any $bundle_of $type_id" => [
        'title' => $this->t('%type_name: View any %bundle_of', $type_params),
      ],
      "view own $bundle_of $type_id" => [
        'title' => $this->t('%type_name: View own %bundle_of', $type_params),
      ],
      "edit any $bundle_of $type_id" => [
        'title' => $this->t('%type_name: Edit any %bundle_of', $type_params),
      ],
      "edit own $bundle_of $type_id" => [
        'title' => $this->t('%type_name: Edit own %bundle_of', $type_params),
      ],
      "delete any $bundle_of $type_id" => [
        'title' => $this->t('%type_name: Delete any %bundle_of', $type_params),
      ],
      "delete own $bundle_of $type_id" => [
        'title' => $this->t('%type_name: Delete own %bundle_of', $type_params),
      ],
    ];
  }
}

There isn’t much exceptional going on here.

  • The entry point method referred to in the permissions yaml file simply loads all PracticalTypeEntitys, loops through them and executes the buildPermissions() method on each one.
  • The buildPermissions() method returns an array of dynamically defined permissions for each entity.
  • And I’m using the StringTranslationTrait to provide my class with the t() method.
permissions generated for the practical entity
Practical entity generated permissions

Access Control

The next part in implementing our custom permissions is to create a new “Access Control Handler”. This is done by extending the core EntityAccessControlHandler class and overriding the checkAccess() and checkCreateAccess() methods.

<?php

namespace Drupal\practical;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;

class PracticalEntityAccessControlHandler extends EntityAccessControlHandler {

  /**
   * {@inheritdoc}
   */
  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {

    /** @var \Drupal\practical\Entity\PracticalEntityInterface $entity */
    $entity_type_id = $entity->getEntityTypeId();
    $bundle = $entity->bundle();
    $is_owner = $entity->getOwnerId() === $account->id();

    switch ($operation) {
      case 'view':
        if ($is_owner) {
          return AccessResult::allowedIfHasPermission($account, "view own $entity_type_id $bundle");
        }
        return AccessResult::allowedIfHasPermission($account, "view any $entity_type_id $bundle");

      case 'update':
        if ($is_owner) {
          return AccessResult::allowedIfHasPermission($account, "edit own $entity_type_id $bundle");
        }
        return AccessResult::allowedIfHasPermission($account, "edit any $entity_type_id $bundle");

      case 'delete':
        if ($is_owner) {
          return AccessResult::allowedIfHasPermission($account, "delete own $entity_type_id $bundle");
        }
        return AccessResult::allowedIfHasPermission($account, "delete any $entity_type_id $bundle");
    }

    // Unknown operation, no opinion.
    return AccessResult::neutral();
  }

  /**
   * {@inheritdoc}
   */
  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
    return AccessResult::allowedIfHasPermission($account, "create {$context['entity_type_id']} $entity_bundle");
  }
}

As long as the logic and the permission pattern in checkAccess() are correct, this is good to go. “But wait!”, you say, “Where did $entity->getOwnerId(); come from?”. Oh yeah, that’s a good question. For this permission/access control plan to work out, we need to update our Content Entity.

… Is it just me, or is this post getting really long? Oh well, no stopping now!

Content Entity

Time to make some significant improvements to our Content Entity. Take a look at this new version, and let’s see what all has changed.

<?php

namespace Drupal\practical\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\user\UserInterface;

/**
 * Defines the Practical entity.
 *
 * @ContentEntityType(
 *   id = "practical",
 *   label = @Translation("Practical"),
 *   base_table = "practical",
 *   entity_keys = {
 *     "id" = "id",
 *     "bundle" = "bundle",
 *     "uid" = "uid",
 *     "label" = "name",
 *     "created" = "created",
 *     "changed" = "changed",
 *   },
 *   fieldable = TRUE,
 *   admin_permission = "administer practical types",
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\practical\PracticalListBuilder",
 *     "access" = "Drupal\practical\PracticalEntityAccessControlHandler",
 *     "views_data" = "Drupal\views\EntityViewsData",
 *     "form" = {
 *       "default" = "Drupal\practical\Form\PracticalEntityForm",
 *       "add" = "Drupal\practical\Form\PracticalEntityForm",
 *       "edit" = "Drupal\practical\Form\PracticalEntityForm",
 *       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   links = {
 *     "canonical" = "/practical/{practical}",
 *     "add-page" = "/practical/add",
 *     "add-form" = "/practical/add/{practical_type}",
 *     "edit-form" = "/practical/{practical}/edit",
 *     "delete-form" = "/practical/{practical}/delete",
 *     "collection" = "/admin/content/practicals",
 *   },
 *   bundle_entity_type = "practical_type",
 *   field_ui_base_route = "entity.practical_type.edit_form",
 * )
 */
class PracticalEntity extends ContentEntityBase implements PracticalEntityInterface {

  use EntityChangedTrait;

  /**
   * {@inheritdoc}
   */
  public function getName() {
    return $this->get('name')->value;
  }

  /**
   * {@inheritdoc}
   */
  public function setName($name) {
    $this->set('name', $name);
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getCreatedTime() {
    return $this->get('created')->value;
  }

  /**
   * {@inheritdoc}
   */
  public function setCreatedTime($timestamp) {
    $this->set('created', $timestamp);
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function getOwner() {
    return $this->get('uid')->entity;
  }

  /**
   * {@inheritdoc}
   */
  public function getOwnerId() {
    return $this->get('uid')->target_id;
  }

  /**
   * {@inheritdoc}
   */
  public function setOwner(UserInterface $account) {
    $this->set('uid', $account->id());
    return $this;
  }
  /**
   * {@inheritdoc}
   */
  public function setOwnerId($uid) {
    $this->set('uid', $uid);
    return $this;
  }
  
  /**
   * {@inheritdoc}
   */
  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
    $fields = parent::baseFieldDefinitions($entity_type);

    $fields['uid'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(t('Authored by'))
      ->setDescription(t('The user ID of author of the Practical entity.'))
      ->setSetting('target_type', 'user')
      ->setSetting('handler', 'default')
      ->setDisplayOptions('view', [
        'label' => 'hidden',
        'type' => 'author',
        'weight' => 0,
      ])
      ->setDisplayOptions('form', [
        'type' => 'entity_reference_autocomplete',
        'weight' => 5,
        'settings' => [
          'match_operator' => 'CONTAINS',
          'size' => '60',
          'autocomplete_type' => 'tags',
          'placeholder' => '',
        ],
      ])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    $fields['name'] = BaseFieldDefinition::create('string')
       ->setLabel(t('Name'))
       ->setDescription(t('The name of the Practical entity.'))
       ->setSettings([
         'max_length' => 50,
         'text_processing' => 0,
       ])
       ->setDefaultValue('')
       ->setDisplayOptions('view', [
         'label' => 'hidden',
         'type' => 'string',
         'weight' => -4,
       ])
       ->setDisplayOptions('form', [
         'type' => 'string_textfield',
         'weight' => -4,
       ])
       ->setDisplayConfigurable('form', TRUE)
       ->setDisplayConfigurable('view', TRUE);

    $fields['created'] = BaseFieldDefinition::create('created')
      ->setLabel(t('Created'))
      ->setDescription(t('The time that the entity was created.'));

    $fields['changed'] = BaseFieldDefinition::create('changed')
      ->setLabel(t('Changed'))
      ->setDescription(t('The time that the entity was last edited.'));

    return $fields;
  }

  /**
   * {@inheritdoc}
   */
  public static function preCreate(EntityStorageInterface $storage_controller, array &$values) {
    parent::preCreate($storage_controller, $values);
    $values += [
      'uid' => \Drupal::currentUser()->id(),
    ];
  }

}

New Entity Keysuid, label, created, and changed. Take note of the label as it is the only entity key we have aliased. Its alias is “name”, which means the base field “name” will be mapped to the Entity’s label property, and the column created in the database for the label property will be “name”.

Access Handler – Points to our new PracticalEntityAccessControlHandler class.

Base Field Definitions – Now that we are using some entity_keys that are not automagically converted into base fields, we need to manually define fields for the new ones. Summary of new base fields:

  • uid – entity_reference field that targets user entities.
  • name – Simple string field. This is the field that will be aliased as the entity’s label.
  • created – This field will track the date/time for when the entity was created.
  • changed – This field will track the date/time for when the entity was last updated.

Getters & Setters – Simple functions that set, or return the values of some of our new fields. Note how there are no gettings & setters for the changed field. The changed getters & setters are provided by the EntityChangedTrait.

preCreate() method – Overrides the preCreate() method for the parent class. We’re using it to pre-populate the uid field with the current user’s uid.

implements PracticalEntityInterface – Attempting to follow best practices, this entity is now programmed to an interface.

Content Entity Interface

The new PracticalEntityInterface simply describes methods the PracticalEntity must define.

<?php

namespace Drupal\practical\Entity;

use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\user\EntityOwnerInterface;

/**
 * Provides an interface for defining Practical entity entities.
 *
 * @ingroup practical
 */
interface PracticalEntityInterface extends EntityChangedInterface, EntityOwnerInterface {

  /**
   * Gets the Practical entity name.
   *
   * @return string
   *   Name of the Practical entity.
   */
  public function getName();

  /**
   * Sets the Practical entity name.
   *
   * @param string $name
   *   The Practical entity name.
   *
   * @return \Drupal\practical\Entity\PracticalEntityInterface
   *   The called Practical entity entity.
   */
  public function setName($name);

  /**
   * Gets the Practical entity creation timestamp.
   *
   * @return int
   *   Creation timestamp of the Practical entity.
   */
  public function getCreatedTime();

  /**
   * Sets the Practical entity creation timestamp.
   *
   * @param int $timestamp
   *   The Practical entity creation timestamp.
   *
   * @return \Drupal\practical\Entity\PracticalEntityInterface
   *   The called Practical entity entity.
   */
  public function setCreatedTime($timestamp);
}

In an ideal world, all of our classes are programmed to an interface. But for now, I’ve focused only on the two Entity classes.

practical entity form with name and owner fields
Practical Entity form with Name and Owner fields

Content Entity List Builder

Now that we have much more relevant information concerning each content entity we could display, let’s update the List Builder to show that information.

<?php

namespace Drupal\practical;

use Drupal\Core\Datetime\DateFormatter;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Render\RendererInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Class PracticalTypeListBuilder
 */
class PracticalListBuilder extends EntityListBuilder {

  /**
   * The date formatter service.
   *
   * @var \Drupal\Core\Datetime\DateFormatter
   */
  protected $dateFormatter;

  /**
   * The renderer.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

  /**
   * Constructs a new PracticalListBuilder object.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type definition.
   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
   *   The entity storage class.
   * @param \Drupal\Core\Datetime\DateFormatter $date_formatter
   *   The date formatter service.
   * @param \Drupal\Core\Render\RendererInterface $renderer
   *   The renderer.
   */
  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, DateFormatter $date_formatter, RendererInterface $renderer) {
    parent::__construct($entity_type, $storage);

    $this->dateFormatter = $date_formatter;
    $this->renderer = $renderer;
  }

  /**
   * {@inheritdoc}
   */
  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
    return new static(
      $entity_type,
      $container->get('entity.manager')->getStorage($entity_type->id()),
      $container->get('date.formatter'),
      $container->get('renderer')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function buildHeader(){
    $header['id'] = $this->t('Linked Entity Label');
    $header['bundle_id'] = $this->t('Bundle');
    $header['owner'] = $this->t('Owner');
    $header['created'] = $this->t('Created');
    $header['changed'] = $this->t('Changed');

    return $header + parent::buildHeader();
  }

  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    /** @var \Drupal\practical\Entity\PracticalEntityInterface $entity */
    $row['id'] = $entity->toLink($entity->label());
    $row['bundle_id'] = $entity->bundle();
    $row['owner'] = $entity->getOwner()->toLink($entity->getOwner()->label());
    $row['created'] = $this->dateFormatter->format($entity->getCreatedTime(), 'short');
    $row['changed'] = $this->dateFormatter->format($entity->getChangedTime(), 'short');

    return $row + parent::buildRow($entity);
  }

}

The important changes here are that we’re now showing our new fields on the Entity’s collection list. For us to show the created and changed fields as formatted dates instead of timestamps, we need to ask Drupal’s dependency injection service to provide us with the date formatter.

To accept dependency injected services, we need to create the static method createInstance() and within it return a static instance of the object, passing in services from the dependency container as parameters. In the class’s constructor, we expect the new services to be passed in, and assign them to properties on the object.

Long story short, we can now use the date.formatter and renderer services within our object as the dateFormatter and renderer properties respectively.

Currently this class is only using the date.formatter service to output the created and changed timestamps as dates. And though this example doesn’t use the renderer, it is a common dependency we might want in the future.

practical entity list builder
Practical Entity list builder

Config Entity

The Config Entity (which handles our Bundles) has to be updated much less in order to provide a new description field.

<?php

namespace Drupal\practical\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBundleBase;

/**
 * Defines the Practical Type entity. A configuration entity used to manage
 * bundles for the Practical entity.
 *
 * @ConfigEntityType(
 *   id = "practical_type",
 *   label = @Translation("Practical Type"),
 *   bundle_of = "practical",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *     "uuid" = "uuid",
 *   },
 *   config_prefix = "practical_type",
 *   config_export = {
 *     "id",
 *     "label",
 *     "description",
 *   },
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\practical\PracticalTypeListBuilder",
 *     "form" = {
 *       "default" = "Drupal\practical\Form\PracticalTypeEntityForm",
 *       "add" = "Drupal\practical\Form\PracticalTypeEntityForm",
 *       "edit" = "Drupal\practical\Form\PracticalTypeEntityForm",
 *       "delete" = "Drupal\Core\Entity\EntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   admin_permission = "administer practical types",
 *   links = {
 *     "canonical" = "/admin/structure/practical_type/{practical_type}",
 *     "add-form" = "/admin/structure/practical_type/add",
 *     "edit-form" = "/admin/structure/practical_type/{practical_type}/edit",
 *     "delete-form" = "/admin/structure/practical_type/{practical_type}/delete",
 *     "collection" = "/admin/structure/practical_type",
 *   }
 * )
 */
class PracticalTypeEntity extends ConfigEntityBundleBase implements PracticalTypeEntityInterface {

  /**
   * The machine name of the practical type.
   *
   * @var string
   */
  protected $id;

  /**
   * The human-readable name of the practical type.
   *
   * @var string
   */
  protected $label;

  /**
   * A brief description of the practical type.
   *
   * @var string
   */
  protected $description;
  
  /**
   * {@inheritdoc}
   */
  public function getDescription() {
    return $this->description;
  }

  /**
   * {@inheritdoc}
   */
  public function setDescription($description) {
    $this->description = $description;
    return $this;
  }

}

"description" – added to the config_export array, so that it is exported along with a Bundle’s configuration.

Getters & Setters – New methods for setting and returning the value of our description property.

Protected Properties – Local class properties for storing the values of our entity.

implements PracticalTypeEntityInterface – Best practices!

Config Entity Interface

This interface doesn’t do much at the moment, but it’s worth noting that it extends a core provided interfaces for the Description.

<?php

namespace Drupal\practical\Entity;

use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityDescriptionInterface;

/**
 * Provides an interface for defining Practical entity type entities.
 */
interface PracticalTypeEntityInterface extends ConfigEntityInterface, EntityDescriptionInterface {}

Config Entity Form

Little has changed with the Config Entity Form, but we need to create a new description field so that the administrator can easily provide the Bundle’s description value.

<?php

namespace Drupal\practical\Form;

use Drupal\Core\Entity\BundleEntityFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\field_ui\FieldUI;

class PracticalTypeEntityForm extends BundleEntityFormBase {

  /**
   * {@inheritdoc}
   */
  public function form(array $form, FormStateInterface $form_state) {
    $form = parent::form($form, $form_state);

    /** @var \Drupal\practical\Entity\PracticalTypeEntityInterface $entity_type */
    $entity_type = $this->entity;
    $content_entity_id = $entity_type->getEntityType()->getBundleOf();

    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Label'),
      '#maxlength' => 255,
      '#default_value' => $entity_type->label(),
      '#description' => $this->t("Label for the %content_entity_id entity type (bundle).", ['%content_entity_id' => $content_entity_id]),
      '#required' => TRUE,
    ];

    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $entity_type->id(),
      '#machine_name' => [
        'exists' => '\Drupal\practical\Entity\PracticalTypeEntity::load',
      ],
      '#disabled' => !$entity_type->isNew(),
    ];

    $form['description'] = [
      '#title' => $this->t('Description'),
      '#type' => 'textarea',
      '#default_value' => $entity_type->getDescription(),
      '#description' => $this->t('This text will be displayed on the "Add %content_entity_id" page.', ['%content_entity_id' => $content_entity_id]),
    ];

    return $this->protectBundleIdElement($form);
  }

  /**
   * {@inheritdoc}
   */
  protected function actions(array $form, FormStateInterface $form_state) {
    $actions = parent::actions($form, $form_state);

    if (\Drupal::moduleHandler()->moduleExists('field_ui') && $this->getEntity()->isNew()) {
      $actions['save_continue'] = $actions['submit'];
      $actions['save_continue']['#value'] = $this->t('Save and manage fields');
      $actions['save_continue']['#submit'][] = [$this, 'redirectToFieldUi'];
    }

    return $actions;
  }

  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface $form_state) {
    $entity_type = $this->entity;
    $status = $entity_type->save();
    $message_params = [
      '%label' => $entity_type->label(),
      '%content_entity_id' => $entity_type->getEntityType()->getBundleOf(),
    ];

    switch ($status) {
      case SAVED_NEW:
        drupal_set_message($this->t('Created the %label %content_entity_id entity type.', $message_params));
        break;

      default:
        drupal_set_message($this->t('Saved the %label %content_entity_id entity type.', $message_params));
    }

    $form_state->setRedirectUrl($entity_type->toUrl('collection'));
  }

  /**
   * Form submission handler to redirect to Manage fields page of Field UI.
   *
   * @param array $form
   * @param FormStateInterface $form_state
   */
  public function redirectToFieldUi(array $form, FormStateInterface $form_state) {
    $route_info = FieldUI::getOverviewRouteInfo($this->entity->getEntityType()->getBundleOf(), $this->entity->id());

    if ($form_state->getTriggeringElement()['#parents'][0] === 'save_continue' && $route_info) {
      $form_state->setRedirectUrl($route_info);
    }
  }
}
entity type form with description field
Practical Entity Type form with description

Config Entity List Builder

The only thing that has changed in the Config’s List Builder is that we are now showing the value of the new description field.

<?php

namespace Drupal\practical;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;

/**
 * Class PracticalTypeListBuilder
 */
class PracticalTypeListBuilder extends EntityListBuilder {

  /**
   * {@inheritdoc}
   */
  public function buildHeader(){
    $header['label'] = $this->t('Label');
    $header['description'] = $this->t('Description');
    $header['id'] = $this->t('Machine name');

    return $header + parent::buildHeader();
  }

  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    /** @var \Drupal\practical\Entity\PracticalTypeEntityInterface $entity */
    $row['label'] = $entity->label();
    $row['description'] = $entity->getDescription();
    $row['id'] = $entity->id();

    return $row + parent::buildRow($entity);
  }
}
practical entity type list builder
Practical Entity Type list builder

Results

There we have it. A fairly practical Custom Entity with Bundles. Complete with good administration experience, and some of the fields we all expect having worked with Nodes so much.

Future improvements: Some additional features you might like your custom entity to have are Revisions and Translations. But for now, this will do quite nicely.

References

Resources used to put all this together

See anything in this post that is incorrect or you think could be better explained? Let me know below!

About the Author

Jonathan Daggerhart

Long time Drupal and WordPress developer. I like to write modules and plugins, and I dabble in frontend and design.

2 thoughts on “Drupal 8: Custom Entities with Bundles

Leave a Reply

Your email address will not be published. Required fields are marked *