Drupal 8 Content Entity Bundles – Part 4: Practical Content Entity with Bundles

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


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

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!

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.

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.

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.

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.

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.

"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.

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.

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.

practical entity type list builder
Practical Entity Type list builder


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.


9 Thoughts


John Money
February 22, 2018

Really helpful, thanks!

May 30, 2018


Thank you very much for this article which I followed successfully!

I wonder however: what are the benefits of ListBuilder over a custom view?


Jonathan Daggerhart
May 30, 2018

Hi Ludo,

I think a custom view would be the better option if you can package it up with the entity module, I just haven’t done that yet myself and I’m not sure what all it would take. If you know how to do that, or find a good resource that explains it, I’d love to know about it.


May 31, 2018

Hi Jonathan,

This how I achived replacing the ListBuilder by a view in my module:
1. Create a view
2. Export it in a yml file and remove first line (uuid)
3. Copy this file to config/install folder of your module
4. Remove list_builder attribute from your @ContentEntityType class
5. Change the route_name of the list to the route_name of the view in yourmodule.links.menu.yml
6. Change the route_name of the Add action to the route_name of the view in appears_on in yourmodule.links.menu.yml
7. Remove the ListBuilder class

Following resources helped me to get this result:

Jonathan Daggerhart
May 31, 2018

Oh wow, that seems really straight forward. I’ll be updating my modules to this technique.


Vinayak Anivase
December 14, 2018

This is much better than the official documentation. Thank a lot. :)

February 14, 2019

Hi Jonathan,

it was really helpful, thanks :)
Do you have any idea how to change the entity “add-page” site? I add an image field to config entity and i would like to see it on this page.

April 9, 2019

Excellent series of posts, covering what is for many first-timers a difficult concept.

Don\’t be tempted to get Drupal Console generate:entity:content to do all the legwork. First of all, there is an incompatibility between DC 1.8.0 and D8.7.0_beta1, so I hit a few database update errors which were very difficult to reverse out of.

And secondly, you will learn so much more by taking time to read these posts in-depth.

thx much. :-)

Marc González Majoral
February 14, 2020

Thanks for this post, very helpful.

I found a consistency problem in respect to what core\’s node module does with content permissions though. As can be seen in node.api.php\’s hook_node_access(), it first checks if the user has the \”edit any $type content\” permission and, if that\’s the case, it grants permission without taking into account who is the owner. This also looks to me the proper behaviour when saying \”Edit any…\”.

In your code\’s case, and if I\’m not mistaken, an user won\’t be able to edit/delete their own content entities unless they specifically have the \”edit/delete own…\” permission, even if they have the \”edit/delete any…\” one. I think it should either work like core\’s node or change the permission name to \”Edit/Delete others\’ $bundle_of\”, instead of any.

The same thing happens with the \”view\” permissions, but in this case node also takes into account the published status and other factors. So an user with the \”View any…\” permission won\’t be able to view their own entities unless they also have the \”View own…\” permission.

I fixed it by adding a hasPermission call to the $is_owner conditional:
if ($is_owner && $account->hasPermission(\”edit own $entity_type_id $bundle\”)) {
That same call happens inside AccessResult::allowedIfHasPermission but it\’s cached, so no extra queries.

And on a side note, there\’s also the `Drupal\\user\\EntityOwnerTrait` that handles the Owner stuff for you.

February 16, 2020

The best article about the custom entity creation I ever saw. Thanks.

September 28, 2021

Thank you very much for this article! I think a nice “extra” would be to allow the custom entity to use the menu UI so that the entity creation form allows you to add a menu entry as well. Would that be possible? And if yes, where should I start looking in order to create my own implementation for this functionality?

Leave a Reply

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