Designing Custom Entities for Secondary Databases, Bundles, and Storage Handlers in Drupal
Posted by
admin
Tuesday April
21st
, 2026 7:36
p.m.
Let's explore what a bundle actually is at the framework level.
Does a Bundle Require a DB Column?
No. The column is an artifact of the default SQL storage handler, not a requirement of the bundle system itself.
In the standard SqlContentEntityStorage path, the bundle entity key maps to a column in your base table so the storage layer knows which bundle to assign when hydrating rows into entity objects. But that mapping is entirely the storage handler's concern. The bundle system itself is just a classification layer — it lives in Drupal config and is used to organize display modes, attached fields, permissions, and Layout Builder layouts. None of that requires a column in any table.
When you replace the storage handler, you take over the responsibility of telling Drupal what bundle a given entity instance belongs to. You can do that however makes sense for your data.
Approaches for a Read-Only External Table
Option 1: Single hardcoded bundle (no column at all)
If everything in your external table is conceptually one type of thing, don't bother with a discriminator column. Just hardcode the bundle in your storage handler:
// In your custom storage class
protected function doLoadMultiple(?array $ids = NULL): array {
$records = $this->fetchFromExternalDb($ids);
$entities = [];
foreach ($records as $id => $record) {
$values = $this->mapRecordToValues($record);
$values['type'] = 'default'; // hardcoded bundle — no column needed
$entities[$id] = $this->entityType
->getClass()::create($values);
}
return $entities;
}
Then ship a single bundle config entity in config/install/ and you're done. The external table never needs to know a bundle exists.
Option 2: Derive the bundle from an existing column
If your external table has a field that functionally acts as a type discriminator — say a category, product_line, or record_type column — you can map that to the Drupal bundle without renaming it or altering the table:
// Entity annotation — the key name doesn't have to match the column name
// as long as your storage handler interprets it correctly
entity_keys = {
"id" = "external_id",
"bundle" = "record_type", // maps to whatever column you choose
"label" = "name",
}
In your storage handler, just read that column from the result set and assign it as the bundle value during hydration. Drupal doesn't care what the column is named — it just needs a string value that matches a known bundle config entity ID.
Option 3: Bundle derived from business logic, not a column
You can assign the bundle entirely from logic applied during hydration — no column involved at all:
protected function mapRecordToValues(\stdClass $record): array {
return [
'id' => $record->ext_id,
'label' => $record->name,
'type' => $this->resolveBundleFromRecord($record), // computed
// ...other field mappings
];
}
private function resolveBundleFromRecord(\stdClass $record): string {
// derive from price tier, status, category, whatever makes sense
if ($record->price > 10000) {
return 'premium';
}
return 'standard';
}
The Bundle Config Entity Lives Entirely in Drupal
This is the key insight: the bundle config entity (ProductType, etc.) has no connection to your external table whatsoever. It lives in Drupal's config system, period. Your config/install/ directory ships the bundle definitions, and Drupal manages them entirely independently of where the content entities are stored.
Drupal config system External read-only DB
───────────────────── ────────────────────────
mymodule.product_type.standard.yml SELECT * FROM ext_products
mymodule.product_type.premium.yml ↑
↑ custom storage handler
│ reads rows, assigns bundle
└── Layout Builder config string during hydration
└── Field attach config
└── View mode config
└── Permissions
The storage handler is the bridge. It reads from the external table and stamps a bundle string onto each entity during hydration. Drupal then uses that string to look up the corresponding config entity and apply the correct display config, fields, etc.
Practical Scaffolding for a Read-Only External Entity
class ExternalProductStorage extends ContentEntityStorageBase {
/**
* {@inheritdoc}
*/
protected function doLoadMultiple(?array $ids = NULL): array {
$connection = Database::getConnection('default', 'external');
$query = $connection->select('ext_products', 'p')
->fields('p');
if ($ids !== NULL) {
$query->condition('p.ext_id', $ids, 'IN');
}
$records = $query->execute()->fetchAllAssoc('ext_id');
$entities = [];
foreach ($records as $id => $record) {
$entities[$id] = $this->entityClass::create([
'id' => $record->ext_id,
'type' => 'standard', // or computed
'title' => $record->name,
// ... etc
]);
}
return $entities;
}
/**
* {@inheritdoc}
*/
protected function doSave($id, EntityInterface $entity): int {
// Read-only — do nothing or throw.
throw new \RuntimeException('External product storage is read-only.');
}
/**
* {@inheritdoc}
*/
protected function doDelete($entities): void {
throw new \RuntimeException('External product storage is read-only.');
}
/**
* {@inheritdoc}
*/
protected function getQueryServiceName(): string {
return 'entity.query.null'; // or implement a custom query class
}
/**
* {@inheritdoc}
*/
public function countFieldData($storage_definition, $as_bool = FALSE) {
return $as_bool ? FALSE : 0;
}
}
One thing to be aware of: ContentEntityStorageBase has several abstract methods you'll need to implement or stub. The ones that matter most are doLoadMultiple(), doSave(), doDelete(), and getQueryServiceName(). For a read-only external source the query service is also worth thinking about — if you want \Drupal::entityQuery('product') to work, you need a custom query implementation; if you don't need that, returning entity.query.null is a reasonable stub.
Summary
Scenario | Bundle column needed? | Where bundle lives |
|---|---|---|
Standard SQL entity | Yes, in base table | Config + column in sync |
Custom SQL, multi-bundle | Optional — can be any column | Config, mapped in storage handler |
External read-only, single type | No | Config only, hardcoded in handler |
External read-only, multi-type | Only if a discriminator column exists | Config, derived in handler |
External read-only, logic-based | No | Config, computed in handler |
The bundle is always a Drupal config concept. Your storage handler's only job is to produce a valid bundle string when hydrating entities — how it arrives at that string is entirely up to you.