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.