Understanding Drupal Entity Types: Content Types vs Bundles

Posted by admin
Saturday February 14th , 2026 11:47 a.m.



In Drupal 11, bundle and content type are related but distinct concepts:


Content Type vs Bundle


Content Type is a specific type of bundle used for Node entities only. When you create a content type like “Article” or “Page” in the Drupal UI, you’re creating a bundle of the Node entity type.


Bundle is the broader, more generic term that applies across all fieldable entity types in Drupal. It’s a subtype or variation of an entity type that can have its own set of fields.


Examples Across Entity Types


    ∙    Node entity → bundles are called “content types” (Article, Page, Blog post)
    ∙    Taxonomy term entity → bundles are “vocabularies” (Tags, Categories)
    ∙    User entity → typically has just one bundle (user)
    ∙    Media entity → bundles are “media types” (Image, Video, Document)
    ∙    Paragraph entity → bundles are “paragraph types”
    ∙    Custom entity → you define your own bundle types


In Practice


All content types are bundles, but not all bundles are content types. The term “content type” is essentially Drupal’s user-friendly label for “node bundles” in the admin UI.


From a technical/API perspective, when you’re working with entity queries or the Entity API, you’ll use the bundle machine name regardless of whether it’s a content type or another kind of bundle. 

The underlying architecture treats them the same way—they’re all just bundles that allow different field configurations on the same base entity type.​​​​​​​​​​​​​​​​

 

 

More on Bundles

 

Drupal bundles are essentially subtypes of an entity type. You’ve got an entity type like Node, and then bundles are the specific flavors of Node — Article, Basic Page, Landing Page, etc. Same underlying storage and API, different field configurations and behavior.
The entity type defines the schema skeleton and the code that operates on it. The bundle defines which fields are attached, how they’re displayed (view modes), how they’re edited (form modes), and any bundle-specific permissions. Two nodes of different bundles live in the same node and node_field_data tables but can have completely different field sets because fields are attached per-bundle via config.
Not every entity type is bundleable. Users are a classic example — one bundle, baked in. Content entities declare bundle support in their annotation (bundle_entity_type = "node_type" on Node, for instance), and the bundle itself is usually a config entity that stores the bundle’s settings. So node_type is the config entity that defines Node bundles, taxonomy_vocabulary defines Term bundles, paragraphs_type defines Paragraph bundles, media_type defines Media bundles, and so on.
A few practical things worth knowing: you can target bundles in hooks and event subscribers (hook_ENTITY_TYPE_BUNDLE_presave etc.), you can restrict entity reference fields to specific bundles, and Layout Builder / Display Modes / Field UI all operate at the bundle level. When you’re writing a custom module and you find yourself wanting “this logic only for Article nodes,” the bundle machine name is your discriminator — $node->bundle() === 'article'.​​​​​​​​​​​​​​​​

 

 

Drupal Bundles in Depth
The entity/bundle/field hierarchy
Drupal’s content model has three layers that are easy to conflate:
    1.    Entity type — the class of thing (Node, User, Taxonomy Term, Media, Paragraph, Comment, File, Block Content). Defined in code via a PHP class annotated with @ContentEntityType or @ConfigEntityType. Controls storage, access, routing, the base fields every instance has.
    2.    Bundle — a named variant of an entity type. “Article” is a bundle of Node. “Tags” is a bundle of Taxonomy Term. Defined in configuration, not code (usually — you can define bundles in code, and Paragraphs modules sometimes do).
    3.    Field — a piece of data attached either to the entity type as a whole (a base field, defined in baseFieldDefinitions()) or to a specific bundle (a configurable field, attached via Field UI or config YAML).
So when someone creates an Article, the node entity has base fields like nid, uuid, title, created, uid (those come from Node::baseFieldDefinitions()), plus whatever configurable fields have been attached to the article bundle — body, field_image, field_tags, etc.
What the bundle actually is, structurally
A bundle is typically a config entity. For nodes, the bundle is a node_type config entity. Its config file looks like core.entity.node_type.article.yml (roughly) and contains things like the human-readable label, description, default settings for new nodes of that type, and whether to show the author/date info. The bundle config entity itself is lightweight — most of the interesting per-bundle configuration lives in other config entities that reference the bundle:
    •    field.field.node.article.body — attaches the body field to the article bundle
    •    core.entity_view_display.node.article.default — how Article nodes render in “default” view mode
    •    core.entity_view_display.node.article.teaser — how Article nodes render in “teaser” view mode
    •    core.entity_form_display.node.article.default — how the Article edit form is laid out
The pattern {entity_type}.{bundle}.{mode} is everywhere once you start looking.
Why this matters architecturally
Because bundles are config and fields are attached per-bundle, you can have radically different field sets on two entities of the same type. An article node and a landing_page node share zero configurable fields if you set it up that way, but both go through the same NodeStorage, the same node_access system, the same hook_node_* hooks, the same routes (/node/{node}, /node/{node}/edit), and the same render pipeline. That’s the power of the bundle abstraction: code that operates on “all nodes” keeps working, while content modelers get per-bundle flexibility.
This is also why entity_reference fields can be restricted to specific bundles — the “Target bundles” setting on an entity reference field. You’re saying “this field references Node, but only Article and News bundles, please.”
Which entity types support bundles
Not all entity types are bundleable. The entity type declares this in its annotation. Key signals:
    •    bundle_entity_type — the machine name of the config entity that defines bundles. Node has bundle_entity_type = "node_type". If this key is present, the entity type is bundleable via config entities.
    •    bundle_label — human label for the bundle concept (“Content type” for Node, “Vocabulary” for Term).
    •    If bundle_entity_type is absent, the entity type either has no bundles (User) or has a single implicit bundle with the same name as the entity type (many simple custom entities do this).
Common bundleable entity types and their bundle entity types:

 

|Entity type    |Bundle entity type   |Bundle examples              |
|---------------|---------------------|-----------------------------|
|`node`         |`node_type`          |article, page, landing_page  |
|`taxonomy_term`|`taxonomy_vocabulary`|tags, categories             |
|`paragraph`    |`paragraphs_type`    |text, image, cta             |
|`media`        |`media_type`         |image, document, remote_video|
|`block_content`|`block_content_type` |basic, promo                 |
|`comment`      |`comment_type`       |comment, comment_node_article|
|`user`         |*(none)*             |— (single bundle, “user”)    |
|`file`         |*(none)*             |—                            |

Targeting bundles in code
Three patterns you’ll use constantly:
Bundle-specific hooks. Many entity hooks have a bundle-specific variant where the bundle name is baked into the hook name:

function mymodule_node_presave(NodeInterface $node) {
 // Runs for every node.
}

function mymodule_node_article_presave(NodeInterface $node) {
 // Runs only for article nodes. No need to check bundle() yourself.
}


Core invokes both — the general one and the bundle-specific one. Handy for keeping code focused.
Explicit bundle checks. When you’re in an event subscriber or a generic hook and need to branch on bundle:

if ($entity->getEntityTypeId() === 'node' && $entity->bundle() === 'article') {
 // article-specific logic
}


Bundle-targeted services. Plugin types like field formatters, widgets, and entity view builders can declare bundle-level support. Layout Builder operates per-bundle per-view-mode. When you write a custom plugin that should only apply to certain bundles, you usually filter at config-save time or at runtime based on the entity’s bundle.
Bundles and Field API
The important mechanical detail: EntityFieldManagerInterface::getFieldDefinitions($entity\_type\_id, $bundle) returns base fields *plus* configurable fields attached to that bundle. getBaseFieldDefinitions($entity_type_id) returns only base fields. Most of the time you want the bundle-aware version, because that’s what the entity actually has.

$definitions = \Drupal::service('entity_field.manager')
 ->getFieldDefinitions('node', 'article');


You’ll also use EntityTypeBundleInfoInterface to get the list of bundles for an entity type — useful for form options or validation:

$bundles = \Drupal::service('entity_type.bundle.info')
 ->getBundleInfo('node');
// Returns: ['article' => ['label' => 'Article', ...], 'page' => [...]]


Bundle classes (D9.3+)
A newer piece worth knowing: since Drupal 9.3, you can register a dedicated PHP class for a specific bundle using hook_entity_bundle_info_alter() and the class key. This lets you have Article extends Node with article-specific methods, so instead of $node->get('field\_subtitle')->value scattered everywhere, you can call $article->getSubtitle(). Pairs beautifully with a typed repository pattern for queries. The bundle class doesn’t change storage or the entity type — it’s a runtime wrapper — but it makes domain modeling in code much cleaner.

function mymodule_entity_bundle_info_alter(array &$bundles): void {
 if (isset($bundles['node']['article'])) {
   $bundles['node']['article']['class'] = \Drupal\mymodule\Entity\Article::class;
 }
}


Given your DI/IoC sensibilities, bundle classes are a natural fit — they push bundle-specific behavior out of sprawling procedural helpers and into proper domain objects.
The edge cases that bite
A few things that trip people up:
    •    Changing a node’s bundle after creation is basically not supported. Bundle is effectively immutable post-save. You can hack it, but field data attached to the old bundle won’t migrate, and you’ll corrupt things. Delete and recreate, or use Migrate.
    •    Deleting a bundle deletes all its content. Config entity dependency enforcement will warn you, but it’s still easy to nuke data. Always export config and check what depends on the bundle before deleting.
    •    Field storage vs. field instance. A field_image field storage is defined once per entity type (shared across all bundles of that entity type), but each bundle that uses it has its own field instance config with instance-level settings (label, required, default value, allowed file extensions, etc.). If you delete the last instance, the storage is orphaned and eventually removed.
    •    Per-bundle permissions exist but only for some entity types. Node has per-bundle permissions (create article content, edit own article content). Paragraphs historically did not — paragraph access usually inherits from the parent entity. Media has per-bundle permissions. Check the entity type’s permission provider before assuming.
That should give you a fuller map. If you want, I can go deeper on any one piece — bundle classes with repositories, the Layout Builder bundle integration, or how to write a custom entity type with proper bundle support from scratch.​​​​​​​​​​​​​​​​

 

 

 

 

 

Three deeper cuts
1. Bundle classes with a typed repository
The goal: stop writing $node->get('field_subtitle')->value all over the place and instead talk to proper domain objects. Bundle classes give you the runtime class swap; a typed repository gives you a query surface that returns those domain objects with real types.
Start with an interface so consumers depend on the contract, not the Node implementation:

namespace Drupal\publishing\Entity;

use Drupal\node\NodeInterface;

interface ArticleInterface extends NodeInterface {
 public function getSubtitle(): ?string;
 public function getPublishedDate(): ?\DateTimeImmutable;
 public function getAuthorByline(): string;
 public function isFeatured(): bool;
}


The bundle class extends Node and implements the interface:

namespace Drupal\publishing\Entity;

use Drupal\node\Entity\Node;

class Article extends Node implements ArticleInterface {

 public function getSubtitle(): ?string {
   $value = $this->get('field_subtitle')->value;
   return $value !== '' ? $value : NULL;
 }

 public function getPublishedDate(): ?\DateTimeImmutable {
   $timestamp = $this->get('field_published_at')->value;
   return $timestamp
     ? new \DateTimeImmutable('@' . $timestamp)
     : NULL;
 }

 public function getAuthorByline(): string {
   // Domain logic that would otherwise be scattered across
   // twig preprocess, controllers, and blocks.
   $override = $this->get('field_byline_override')->value;
   if ($override) {
     return $override;
   }
   $author = $this->getOwner();
   return $author?->getDisplayName() ?? $this->t('Staff');
 }

 public function isFeatured(): bool {
   return (bool) $this->get('field_featured')->value;
 }
}


Register it:

function publishing_entity_bundle_info_alter(array &$bundles): void {
 if (isset($bundles['node']['article'])) {
   $bundles['node']['article']['class'] = \Drupal\publishing\Entity\Article::class;
 }
}


From this point forward, every Node::load() that hits an article returns an Article object. instanceof ArticleInterface works. Twig sees the subclass. Event subscribers can type-hint against the interface.
The repository layer is where this starts paying real dividends. Instead of entityTypeManager/getStorage('node')/->loadByProperties() scattered across controllers, you get a focused service:

namespace Drupal\publishing\Repository;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\publishing\Entity\ArticleInterface;

final class ArticleRepository {

 public function __construct(
   private readonly EntityTypeManagerInterface $entityTypeManager,
 ) {}

 /**
  * @return ArticleInterface[]
  */
 public function findFeatured(int $limit = 5): array {
   $storage = $this->entityTypeManager->getStorage('node');
   $ids = $storage->getQuery()
     ->accessCheck(TRUE)
     ->condition('type', 'article')
     ->condition('status', 1)
     ->condition('field_featured', 1)
     ->sort('field_published_at', 'DESC')
     ->range(0, $limit)
     ->execute();

   // Every loaded entity is an Article instance because of the
   // bundle class registration. The return type is honest.
   return $storage->loadMultiple($ids);
 }

 public function findBySlug(string $slug): ?ArticleInterface {
   $storage = $this->entityTypeManager->getStorage('node');
   $results = $storage->loadByProperties([
     'type' => 'article',
     'field_slug' => $slug,
   ]);
   return $results ? reset($results) : NULL;
 }
}


Service definition:

services:
 publishing.article_repository:
   class: Drupal\publishing\Repository\ArticleRepository
   arguments: ['@entity_type.manager']


A controller that used to do five things now does one. You inject the repository, call a method, get typed results. Bundle-specific queries stop leaking into presentation layers. This is exactly the DI/IoC argument applied at the domain layer — the repository is a seam where “how do I get articles” becomes pluggable and testable, instead of \Drupal::entityQuery('node')->condition('type', 'article') incantations.
One caveat: the bundle class is a runtime swap only. Storage is still node, the entity type is still Node, hooks still fire as hook_node_*. You’re not changing Drupal’s type system — you’re decorating the loaded object. Don’t add properties that assume custom storage. Keep the class a thin domain wrapper over the fields that already exist on the bundle.
2. Layout Builder’s bundle integration
Layout Builder is, at its core, a system that attaches a list of layout sections to an entity_view_display config entity, then renders the entity by walking those sections instead of the default field-by-field renderer. Because entity_view_display is keyed as {entity_type}.{bundle}.{view_mode}, Layout Builder is inherently per-bundle per-view-mode.
The config shape looks like this (abbreviated):

# core.entity_view_display.node.article.default.yml
id: node.article.default
targetEntityType: node
bundle: article
mode: default
third_party_settings:
 layout_builder:
   enabled: true
   allow_custom: true   # per-entity overrides allowed
   sections:
     - layout_id: layout_twocol
       layout_settings:
         column_widths: '50-50'
       components:
         some-uuid:
           uuid: some-uuid
           region: first
           configuration:
             id: 'field_block:node:article:body'
             label_display: '0'
             formatter:
               type: text_default
           weight: 0


sections is the default layout for every article in default view mode. allow_custom: true means individual articles can override that default, stored on the node itself in a layout_builder__layout field added to the bundle.
The two SectionStorage plugins that make this work:
    •    defaults (DefaultsSectionStorage) — loads/saves the sections on the entity_view_display. This is what you edit at /admin/structure/types/manage/article/display/default/layout.
    •    overrides (OverridesSectionStorage) — loads/saves the sections on an individual entity via the layout_builder__layout field. This is what you edit at /node/123/layout.
When Layout Builder renders, it asks SectionStorageManager to find the storage for the current context (entity + view mode). Overrides win if present; otherwise defaults. The bundle is part of the context because it’s how the defaults storage locates the right entity_view_display.
Enabling LB on a bundle programmatically:

$display = \Drupal::service('entity_display.repository')
 ->getViewDisplay('node', 'article', 'default');
$display->enableLayoutBuilder()
 ->setOverridable(TRUE)   // allow per-entity overrides
 ->save();


setOverridable(TRUE) is what triggers the field addition on the bundle. Under the hood it calls LayoutBuilderEntityViewDisplay::addSectionField() which adds the layout_builder__layout field to that specific bundle. This is bundle-scoped — turning on overrides for articles doesn’t add the field to pages.
The thing worth internalizing: Layout Builder doesn’t define its own per-bundle concept. It rides on the existing bundle system via entity_view_display. If an entity type isn’t bundleable, it effectively has one implicit bundle named after the entity type, and LB still works — there’s just only one layout to configure.
Where this matters for custom code: if you write a SectionStorage plugin (common for contrib like layout_builder_st or custom multi-tenant scenarios), your getContextsDuringPreview() / deriveContextsFromRoute() methods are where you decide how bundle information flows into section lookup. And if you write a block plugin that should only surface in Layout Builder for certain bundles, you filter in blockAccess() or via a plugin.filter service based on the entity and view_mode contexts Layout Builder passes in.
One more useful hook point: hook_entity_view_alter() still fires after Layout Builder renders, and the render array includes a #layout_builder_element marker on built sections. You can post-process per-bundle if you need to inject cache tags, wrap sections in contextual links, or similar.
3. A custom entity type with proper bundle support
The skeleton has two entities: the content entity (the actual data) and the config entity (the bundle definition). They reference each other through annotations.
The content entity class. The key keys are bundle_entity_type, bundle_label, and entity_keys['bundle']:

namespace Drupal\library\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;

/**
* @ContentEntityType(
*   id = "library_item",
*   label = @Translation("Library item"),
*   bundle_label = @Translation("Library item type"),
*   handlers = {
*     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
*     "list_builder" = "Drupal\library\LibraryItemListBuilder",
*     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
*     "access" = "Drupal\library\LibraryItemAccessControlHandler",
*     "form" = {
*       "default" = "Drupal\library\Form\LibraryItemForm",
*       "add" = "Drupal\library\Form\LibraryItemForm",
*       "edit" = "Drupal\library\Form\LibraryItemForm",
*       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
*     },
*     "route_provider" = {
*       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
*     },
*     "permission_provider" = "Drupal\entity\UncacheableEntityPermissionProvider",
*   },
*   base_table = "library_item",
*   data_table = "library_item_field_data",
*   translatable = TRUE,
*   admin_permission = "administer library items",
*   entity_keys = {
*     "id" = "id",
*     "uuid" = "uuid",
*     "bundle" = "type",
*     "label" = "title",
*     "langcode" = "langcode",
*     "published" = "status",
*   },
*   bundle_entity_type = "library_item_type",
*   field_ui_base_route = "entity.library_item_type.edit_form",
*   links = {
*     "canonical" = "/library/item/{library_item}",
*     "add-page" = "/library/item/add",
*     "add-form" = "/library/item/add/{library_item_type}",
*     "edit-form" = "/library/item/{library_item}/edit",
*     "delete-form" = "/library/item/{library_item}/delete",
*     "collection" = "/admin/content/library",
*   },
* )
*/
class LibraryItem extends ContentEntityBase implements LibraryItemInterface {

 public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
   $fields = parent::baseFieldDefinitions($entity_type);

   $fields['title'] = BaseFieldDefinition::create('string')
     ->setLabel(t('Title'))
     ->setRequired(TRUE)
     ->setTranslatable(TRUE)
     ->setSetting('max_length', 255)
     ->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => -10])
     ->setDisplayConfigurable('form', TRUE)
     ->setDisplayConfigurable('view', TRUE);

   $fields['status'] = BaseFieldDefinition::create('boolean')
     ->setLabel(t('Published'))
     ->setDefaultValue(TRUE);

   $fields['created'] = BaseFieldDefinition::create('created')
     ->setLabel(t('Created'));

   $fields['changed'] = BaseFieldDefinition::create('changed')
     ->setLabel(t('Changed'));

   return $fields;
 }
}


A few things worth flagging. The bundle entity key is type — that’s the column on library_item_field_data that stores the bundle machine name for each row. The bundle_entity_type points at library_item_type, which you’re about to define. The {library_item_type} route parameter in add-form means Drupal auto-generates /library/item/add/book, /library/item/add/journal, etc. — one add form per bundle, which is what you want. field_ui_base_route tells Field UI where to hang the “Manage fields / form display / view display” tabs — at the bundle’s edit form.
The bundle config entity. This is what makes the entity type bundleable:

namespace Drupal\library\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBundleBase;

/**
* @ConfigEntityType(
*   id = "library_item_type",
*   label = @Translation("Library item type"),
*   handlers = {
*     "list_builder" = "Drupal\library\LibraryItemTypeListBuilder",
*     "form" = {
*       "add" = "Drupal\library\Form\LibraryItemTypeForm",
*       "edit" = "Drupal\library\Form\LibraryItemTypeForm",
*       "delete" = "Drupal\Core\Entity\EntityDeleteForm",
*     },
*     "route_provider" = {
*       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
*     },
*   },
*   admin_permission = "administer library items",
*   bundle_of = "library_item",
*   config_prefix = "type",
*   entity_keys = {
*     "id" = "id",
*     "label" = "label",
*     "uuid" = "uuid",
*   },
*   config_export = {
*     "id",
*     "label",
*     "description",
*   },
*   links = {
*     "add-form" = "/admin/structure/library-item-types/add",
*     "edit-form" = "/admin/structure/library-item-types/manage/{library_item_type}",
*     "delete-form" = "/admin/structure/library-item-types/manage/{library_item_type}/delete",
*     "collection" = "/admin/structure/library-item-types",
*   },
* )
*/
class LibraryItemType extends ConfigEntityBundleBase {
 protected string $id;
 protected string $label;
 protected string $description = '';
}


The two keys that close the loop: bundle_of = "library_item" points back at the content entity, and the content entity’s bundle_entity_type pointed here. That bidirectional link is what makes Field UI, the entity type bundle info service, and the entity reference bundle filter all work without any extra configuration. Extending ConfigEntityBundleBase (not just ConfigEntityBase) is what gives you the automatic field cleanup when a bundle is deleted.
Config schema — config/schema/library.schema.yml:

library.type.*:
 type: config_entity
 label: 'Library item type'
 mapping:
   id:
     type: string
     label: 'Machine name'
   label:
     type: label
     label: 'Name'
   description:
     type: text
     label: 'Description'


The library.type.* key matches {module}.{config_prefix}.{id} — that’s how Drupal finds the schema for a config entity named library.type.book.
Permissions — if you use UncacheableEntityPermissionProvider (from contrib entity) or write your own, you get per-bundle permissions automatically: create book library_item, edit own book library_item, etc. Without it, you fall back to the single administer library items permission. For most real-world entity types, you want per-bundle permissions — that’s one of the main reasons to be bundleable in the first place.
Making bundle classes work. Once the above is wired up, the same hook_entity_bundle_info_alter pattern works for your custom entity type. You define Book extends LibraryItem, register it in the hook, and the storage handler returns Book instances when loading rows where type = 'book'.

What I’d suggest as the natural next step: the contrib entity module ships a set of base classes (RevisionableContentEntityBase, EntityOwnerTrait, UncacheableEntityPermissionProvider) that cut probably 60% of the boilerplate above and give you revisioning, ownership, and per-bundle permissions nearly for free. Worth a look if you’re building custom entity types regularly — I can walk through that scaffolding if you want.​​​​​​​​​​​​​​​​

 

 

 

 

 

 

 

 

The entity contrib module as a scaffolding accelerator
The entity module (drupal/entity) is one of those deceptively small contribs that is effectively core-adjacent infrastructure. Commerce Guys maintain it; it’s a dependency of Commerce, Profile, Address, and a lot of serious custom-entity codebases. It exists because the boilerplate for a properly-featured custom content entity — revisionable, translatable, ownable, with per-bundle permissions — is substantial, and core’s base classes don’t compose well enough to cover all the combinations.
What it gives you, roughly in order of “how often you’ll use it”:
    1.    RevisionableContentEntityBase — drop-in replacement for ContentEntityBase that adds revision UI, revision routes, and a clean API. Pairs with RevisionableContentEntityForm and RevisionRouteProvider.
    2.    EntityOwnerTrait + EntityOwnerInterface — implements the “this entity has an author” concern without you hand-writing the uid base field, the getter/setter, and the ownership hooks.
    3.    UncacheableEntityPermissionProvider / EntityPermissionProvider — generates per-bundle permissions (create book library_item, edit own book library_item, etc.) from the entity type annotation alone. Pair with EntityAccessControlHandler to actually enforce them.
    4.    AdminHtmlRouteProvider — route provider that gives you admin-themed add/edit/delete/collection routes with consistent naming. Better than core’s default for content-entity-as-admin-content use cases.
    5.    BundlePluginHandler / BundlePluginInterface — lets bundles be defined as plugins instead of config entities. Commerce uses this: every Commerce product variation type, order item type, etc. is defined by a plugin in a module, not by a config entity the site builder creates. Useful when bundles should ship with code.
    6.    QueryAccessHandler — extends entity access into entity queries and Views, so listing/filtering respects access without you filtering manually.
The effect on a custom entity type is substantial. Let me rewrite the library_item scaffold from the previous response using entity, and you can see the delta directly.
The rewritten content entity

namespace Drupal\library\Entity;

use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\entity\Revision\RevisionableContentEntityBase;
use Drupal\user\EntityOwnerInterface;
use Drupal\user\EntityOwnerTrait;

/**
* @ContentEntityType(
*   id = "library_item",
*   label = @Translation("Library item"),
*   label_collection = @Translation("Library items"),
*   label_singular = @Translation("library item"),
*   label_plural = @Translation("library items"),
*   bundle_label = @Translation("Library item type"),
*   handlers = {
*     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
*     "access" = "Drupal\entity\EntityAccessControlHandler",
*     "query_access" = "Drupal\entity\QueryAccess\QueryAccessHandler",
*     "permission_provider" = "Drupal\entity\UncacheableEntityPermissionProvider",
*     "list_builder" = "Drupal\library\LibraryItemListBuilder",
*     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
*     "form" = {
*       "default" = "Drupal\entity\Form\RevisionableContentEntityForm",
*       "add" = "Drupal\entity\Form\RevisionableContentEntityForm",
*       "edit" = "Drupal\entity\Form\RevisionableContentEntityForm",
*       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
*     },
*     "route_provider" = {
*       "html" = "Drupal\entity\Routing\AdminHtmlRouteProvider",
*       "revision" = "Drupal\entity\Routing\RevisionRouteProvider",
*     },
*   },
*   base_table = "library_item",
*   data_table = "library_item_field_data",
*   revision_table = "library_item_revision",
*   revision_data_table = "library_item_field_revision",
*   translatable = TRUE,
*   revisionable = TRUE,
*   show_revision_ui = TRUE,
*   admin_permission = "administer library items",
*   permission_granularity = "bundle",
*   entity_keys = {
*     "id" = "id",
*     "revision" = "revision_id",
*     "uuid" = "uuid",
*     "bundle" = "type",
*     "label" = "title",
*     "owner" = "uid",
*     "langcode" = "langcode",
*     "published" = "status",
*   },
*   revision_metadata_keys = {
*     "revision_user" = "revision_user",
*     "revision_created" = "revision_created",
*     "revision_log_message" = "revision_log_message",
*   },
*   bundle_entity_type = "library_item_type",
*   field_ui_base_route = "entity.library_item_type.edit_form",
*   links = {
*     "canonical" = "/library/item/{library_item}",
*     "add-page" = "/library/item/add",
*     "add-form" = "/library/item/add/{library_item_type}",
*     "edit-form" = "/library/item/{library_item}/edit",
*     "delete-form" = "/library/item/{library_item}/delete",
*     "collection" = "/admin/content/library",
*     "version-history" = "/library/item/{library_item}/revisions",
*     "revision" = "/library/item/{library_item}/revisions/{library_item_revision}/view",
*     "revision-revert-form" = "/library/item/{library_item}/revisions/{library_item_revision}/revert",
*   },
* )
*/
class LibraryItem extends RevisionableContentEntityBase implements LibraryItemInterface, EntityOwnerInterface {

 use EntityOwnerTrait;

 public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
   $fields = parent::baseFieldDefinitions($entity_type);
   $fields += static::ownerBaseFieldDefinitions($entity_type);

   $fields['title'] = BaseFieldDefinition::create('string')
     ->setLabel(t('Title'))
     ->setRequired(TRUE)
     ->setRevisionable(TRUE)
     ->setTranslatable(TRUE)
     ->setSetting('max_length', 255)
     ->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => -10])
     ->setDisplayConfigurable('form', TRUE)
     ->setDisplayConfigurable('view', TRUE);

   $fields['status'] = BaseFieldDefinition::create('boolean')
     ->setLabel(t('Published'))
     ->setRevisionable(TRUE)
     ->setDefaultValue(TRUE);

   $fields['created'] = BaseFieldDefinition::create('created')
     ->setLabel(t('Created'));

   $fields['changed'] = BaseFieldDefinition::create('changed')
     ->setLabel(t('Changed'));

   return $fields;
 }
}


Count what you didn’t write:
    •    The uid field definition — EntityOwnerTrait::ownerBaseFieldDefinitions() produces it, correctly typed as an entity reference to user with the right default value callback.
    •    getOwner(), setOwner(), getOwnerId(), setOwnerId() — all on the trait.
    •    The full revision API (getRevisionUser, getRevisionCreationTime, getRevisionLogMessage, and their setters) — on RevisionableContentEntityBase.
    •    The revision routes, revision tab, revert form, and revision log UI — from RevisionRouteProvider plus the show_revision_ui = TRUE flag.
    •    The revision metadata base fields (revision_user, revision_created, revision_log_message) — implicit from the revision keys.
    •    The form that handles revision log entry and the “create new revision” checkbox — RevisionableContentEntityForm.
The permission_granularity = "bundle" line combined with UncacheableEntityPermissionProvider gives you, per bundle:
    •    administer {entity_type}
    •    access {entity_type} overview
    •    view {entity_type}
    •    view own unpublished {entity_type}
    •    create {bundle} {entity_type}
    •    update any {bundle} {entity_type} / update own {bundle} {entity_type}
    •    delete any {bundle} {entity_type} / delete own {bundle} {entity_type}
…automatically. Every bundle you add generates its own permission set. No hand-written permission YAML per bundle. This is the main reason Commerce and Profile use this module — they can’t know ahead of time what bundles a site will have, but the permission model needs to scale with them.
What UncacheableEntityPermissionProvider actually does
The “uncacheable” qualifier refers to access results, not the permission list. Core’s default permission provider produces access checks that are cached per-role, which means “edit own” logic can’t be correctly varied per user without cache contamination. UncacheableEntityPermissionProvider pairs with EntityAccessControlHandler to return access results that are not cached at the role level but are cached per-user with the right cache contexts. For most content entity types where ownership matters, this is what you want. If you have no concept of “own” (purely admin-managed entities), EntityPermissionProvider (without the Uncacheable prefix) gives you coarser but cacheable permissions.
Query access — the piece most custom entities skip
QueryAccessHandler is the one that’s easiest to undervalue until you’ve been bitten. Core’s entity access runs on loaded entities — you load 100, it filters to 20 you can see. That’s fine for canonical pages but terrible for Views and listing pages where you want the query itself to return only accessible rows. QueryAccessHandler hooks into entity queries and applies access conditions at the SQL level:

// Automatically restricted to rows the current user can 'view'.
$ids = $storage->getQuery()
 ->accessCheck(TRUE)
 ->condition('type', 'book')
 ->execute();


Views integration comes along for the ride. If you’re building a custom entity type and you intend to list it in Views with per-user visibility, register query_access in the handlers and implement QueryAccessHandlerInterface (or extend the provided base). Without it, Views will happily show rows the user shouldn’t see, or you’ll end up writing custom Views query alter hooks — exactly the “wrapped static call” pattern in a different costume.
Bundle plugins — when bundles belong in code
The config-entity bundle model is right when site builders need to create bundles at runtime: blog sites add content types, editors add vocabularies. But sometimes bundles are the code — Commerce product variation types, payment method types, shipping method types. Each of those is defined by a module, ships with code, and the site builder shouldn’t be adding new ones through the UI.
entity‘s BundlePluginHandler lets you declare that your entity type’s bundles are plugins. Instead of a config entity per bundle, you have:

src/Plugin/LibraryItemType/Book.php
src/Plugin/LibraryItemType/Journal.php
src/Plugin/LibraryItemType/Manuscript.php


Each is an @LibraryItemType-annotated plugin class implementing BundlePluginInterface. They contribute their own base field definitions to the entity type, which means fields can be declared in code per-bundle — a huge deal for deployability. No field.field.library_item.book.* YAML drift across environments. The plugin discovery happens at cache clear, bundle info is computed from the plugin registry.
The trade-off is that Field UI can’t manage them in the way editors expect for content types. You’re saying “bundles are a code concern.” For the right entity type — one where bundles are part of the module’s contract, not a site-building extension point — this is liberating.
The Commerce codebase is the reference implementation. Look at Drupal\commerce_product\Entity\ProductVariation and the commerce_product_variation_type plugin discovery for the full pattern.
Pulling this all together
For a custom entity type you’re building today, the default I’d reach for:
    •    RevisionableContentEntityBase unless you’re certain you’ll never want revisions (you will eventually).
    •    EntityOwnerTrait if the entity has any concept of authorship.
    •    UncacheableEntityPermissionProvider + EntityAccessControlHandler + permission_granularity = "bundle" for per-bundle permissions.
    •    QueryAccessHandler if the entity will ever appear in Views or listings with variable visibility.
    •    AdminHtmlRouteProvider + RevisionRouteProvider for sensible routing.
    •    Config-entity bundles for the site-builder-managed case; bundle plugins for the code-defined case.
    •    A bundle class per bundle (plus a typed repository) once you have more than trivial per-bundle behavior.
The composition here is deliberately granular — it’s why entity has aged well across 8/9/10/11. Each handler is a focused seam, and you opt into each independently. Compare that to a hypothetical FullyFeaturedEntityBase that bundles everything: it would be unusable for half the cases because you’d inherit concerns you don’t want.
Worth reading the module’s own documentation at drupal.org/project/entity and the docblocks on EntityAccessControlHandler and UncacheableEntityPermissionProvider directly — they’re unusually well-written for contrib and spell out the access-result cache contract clearly. If you want, I can go one level further and walk through writing a QueryAccessHandler subclass with custom conditions (e.g., “contributors can only see their own drafts plus all published items”), which is where the pattern gets genuinely interesting.​​​​​​​​​​​​​​​​