Considerations When Designing Custom Drupal Entites (and Using Secondary Databases)

Posted by admin
Tuesday April 21st , 2026 7:16 p.m.



Layout Builder works well with custom entities and external data sources, though there are some architectural considerations worth understanding at each layer. Let me walk through this systematically.


Layout Builder + External Databases

Layout Builder itself doesn't care where your data comes from — it operates on entities. So the key is wrapping your external data in a proper Drupal entity. The two main approaches are:

1. Custom content entity backed by an external DB table You define a content entity that maps to a table in a secondary database connection (configured in settings.php as a $databases['external']['default'] key). You then override the entity's storage handler to query that connection instead of the default. This is the cleanest approach for read/write scenarios.

2. Read-only wrapper with a custom storage backend If the external DB is read-only or you're pulling from an API/legacy system, you implement a custom EntityStorageInterface (typically extending ContentEntityStorageBase) and override doLoadMultiple(), doSave(), etc., to delegate to your external source. Drupal's entity system doesn't mandate SQL at all — the storage backend is fully swappable.

In both cases, once the entity is loadable via \Drupal::entityTypeManager()->getStorage('my_entity'), Layout Builder can work with it normally.


Defining Bundles for a Custom Entity

This is where a lot of developers get tripped up. Bundles are optional on custom entities — you have to explicitly opt into them.

Step 1: Declare bundle support in the entity annotation

/**
 * @ContentEntityType(
 *   id = "product",
 *   label = @Translation("Product"),
 *   bundle_label = @Translation("Product type"),
 *   handlers = {
 *     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\mymodule\ProductListBuilder",
 *     "form" = {
 *       "default" = "Drupal\mymodule\Form\ProductForm",
 *       "add" = "Drupal\mymodule\Form\ProductForm",
 *       "edit" = "Drupal\mymodule\Form\ProductForm",
 *       "delete" = "Drupal\mymodule\Form\ProductDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   base_table = "product",
 *   translatable = FALSE,
 *   admin_permission = "administer product types",
 *   entity_keys = {
 *     "id" = "id",
 *     "bundle" = "type",      ← this is the critical key
 *     "label" = "title",
 *     "uuid" = "uuid",
 *   },
 *   bundle_entity_type = "product_type",   ← points to the config entity below
 *   field_ui_base_route = "entity.product_type.edit_form",
 *   links = {
 *     "canonical" = "/product/{product}",
 *     "add-page" = "/product/add",
 *     "add-form" = "/product/add/{product_type}",
 *     "edit-form" = "/product/{product}/edit",
 *     "delete-form" = "/product/{product}/delete",
 *     "collection" = "/admin/content/products",
 *   }
 * )
 */

Step 2: Create the bundle config entity (the "type")

Bundles are themselves config entities. You need a separate ProductType entity class:

/**
 * @ConfigEntityType(
 *   id = "product_type",
 *   label = @Translation("Product type"),
 *   handlers = {
 *     "form" = {
 *       "add" = "Drupal\mymodule\Form\ProductTypeForm",
 *       "edit" = "Drupal\mymodule\Form\ProductTypeForm",
 *       "delete" = "Drupal\mymodule\Form\ProductTypeDeleteForm",
 *     },
 *     "list_builder" = "Drupal\mymodule\ProductTypeListBuilder",
 *     "route_provider" = {
 *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
 *     },
 *   },
 *   admin_permission = "administer product types",
 *   bundle_of = "product",   ← back-reference to the content entity
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "label",
 *   },
 *   links = {
 *     "add-form" = "/admin/structure/product-types/add",
 *     "edit-form" = "/admin/structure/product-types/{product_type}",
 *     "delete-form" = "/admin/structure/product-types/{product_type}/delete",
 *     "collection" = "/admin/structure/product-types",
 *   },
 *   config_export = {
 *     "id",
 *     "label",
 *     "description",
 *   }
 * )
 */
class ProductType extends ConfigEntityBundleBase implements ProductTypeInterface {

Note ConfigEntityBundleBase — that's the key parent class that wires the config entity into Drupal's bundle system automatically.

Step 3: Provide default config

Drop a YAML file at config/install/mymodule.product_type.basic.yml to ship a default bundle with the module so Field UI has something to work with on install.


View Modes and Display Modes — How They Fit

These two terms are often used interchangeably but refer to slightly different things in Drupal's architecture:

View modes are named configurations that control how an entity is rendered. They're defined in the entity annotation or via config, and they correspond to named render contexts like full, teaser, card, search_index, etc. Every content entity type gets a default view mode automatically.

You define custom view modes in a hook_entity_extra_field_info() implementation or, more correctly, by shipping them as config in core.entity_view_mode.{entity_type}.{view_mode}.yml.

Display modes is the broader umbrella term in Drupal's UI — it covers both view modes (for display) and form modes (for editing forms). When you go to /admin/structure/display-modes, you're managing both.

How they interact with Layout Builder:

Layout Builder operates at the view mode level. When you enable Layout Builder for a bundle, you're enabling it for a specific view mode of that bundle — typically full, but you can enable it per-view-mode independently. This is done at:

/admin/structure/product-types/{product_type}/display/{view_mode}

The architecture looks like this:

Entity Type (product)
  └── Bundle (product_type: "Widget")
        └── View Mode (full)
              └── Layout Builder enabled
                    ├── Default layout (stored in config)
                    └── Per-entity overrides (stored in layout_entity_* tables)

To enable Layout Builder on your custom entity's view mode, you need two things in the entity annotation:

"layout_builder" = "Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay",

Actually, more precisely, you need Layout Builder's LayoutBuilderEntityViewDisplay to be used as the view display class. This happens automatically if you have the layout_builder module enabled and use EntityViewDisplay — Layout Builder hooks into hook_entity_type_alter() to swap in its own class.

The key thing to ensure is that your entity has a proper view_builder handler declared (as shown in the annotation above) and that you're using EntityViewDisplay config entities for your display configuration — which happens by default if you follow the standard entity scaffolding pattern.

Enabling Layout Builder programmatically for a custom entity view mode:

$display = \Drupal::entityTypeManager()
  ->getStorage('entity_view_display')
  ->load('product.widget.full');

if ($display) {
  $display->enableLayoutBuilder()
    ->setOverridable()  // allow per-entity layout overrides
    ->save();
}

Or ship it as install config in core.entity_view_display.product.widget.full.yml with:

third_party_settings:
  layout_builder:
    enabled: true
    allow_custom: true  # per-entity overrides

Summary of the Dependency Chain

External DB
  └── Custom storage handler → hydrates entity objects
        └── Content entity (product) with bundle_entity_type declared
              └── Bundle config entity (product_type) extends ConfigEntityBundleBase
                    └── View modes (full, teaser, card...)
                          └── Layout Builder enabled per view mode
                                └── Sections → Regions → Blocks (fields, custom blocks, etc.)

The one gotcha to watch for: if your entity data lives entirely in an external DB and you're using a custom storage backend, Layout Builder's per-entity layout overrides still write to Drupal's default DB (they're stored as entity data on a layout_builder__layout field). 

That's usually fine — the layout config lives in Drupal, the content data lives externally — but it's worth being explicit about that architectural split with your team.