Series Parts:

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 the core provided interfaces for the Config Entity and Entity 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

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.

5 thoughts on “Drupal 8 Content Entity Bundles – Part 4: Practical Content Entity with Bundles

Leave a Reply

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