When a Custom Entity Beats a Content Type in Drupal
Content type or custom entity? A practical decision framework for Drupal's Entity API, with a worked inspection-record example, EntityQuery usage, and honest trade-offs.
15 Jan 2026, 02:00 UTC

Every Drupal project hits the same fork in the road: the client wants to track something that isn't quite an article, isn't quite a product, and definitely isn't a taxonomy term. Maybe it's equipment inspections, event registrations, or partner listings with their own workflow. The reflex is to create another content type and bolt on fields. Sometimes that's right. But the Entity API gives you a second option — a custom entity type — and picking between them is one of the more consequential early decisions on a build.
The short version of my thesis: use a content type when editors own the data and it behaves like content; reach for a custom entity when the data is structured, programmatically managed, and doesn't belong in the editorial workflow.
What the Entity API actually gives you
Since Drupal 8, the Entity API unifies almost everything under one object-oriented interface. Nodes, users, taxonomy terms, blocks, views, and your own custom types all implement the same EntityInterface. That means the same verbs work everywhere: $entity->save(), \Drupal::entityTypeManager()->getStorage('node')->load($id), field accessors like $entity->get('field_price')->value. Code you write against one entity type transfers to another with minimal changes.
There are two families worth distinguishing:
- Content entities store fieldable, revisionable data — nodes, users, and anything you define with
ContentEntityBase. - Config entities store site configuration — views, image styles, vocabularies. They export to YAML and deploy through configuration management, not the database content tables.
Hooks like hook_entity_presave() let you validate or transform data before persistence, and they fire regardless of whether the save came from a form, a REST call, or a migration. That uniformity is the real payoff.
The decision criteria
Here's the rubric I apply when someone asks "content type or custom entity?"
| Signal | Content type | Custom entity |
|---|---|---|
| Editors create and revise it in the admin UI | Yes | No |
| Needs revisions, moderation, menus, path aliases | Yes | Build it yourself |
| Data arrives via API, migration, or cron | Awkward | Yes |
| Fixed schema, few or no configurable fields | Overkill | Yes |
| Thousands of records queried by properties | Watch performance | Designed for it |
A content type drags along the whole editorial apparatus: node grants, revision tables, pathauto patterns, metatag config. If nobody will ever edit an inspection record by hand, that machinery is dead weight — and worse, it invites editors to touch data they shouldn't.
A worked example: an equipment inspection entity
Say you're tracking safety inspections. Each record has an asset ID, an inspector, a date, and a pass/fail status. Records come from a mobile app via REST; humans only read reports. A custom content entity fits. The minimal definition lives in an annotation on the entity class:
namespace Drupal\safety\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
/**
* @ContentEntityType(
* id = "inspection",
* label = @Translation("Inspection"),
* base_table = "inspection",
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* },
* )
*/
class Inspection extends ContentEntityBase {
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['asset_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Asset ID'))
->setRequired(TRUE);
$fields['status'] = BaseFieldDefinition::create('list_string')
->setLabel(t('Status'))
->setSetting('allowed_values', ['pass' => 'Pass', 'fail' => 'Fail'])
->setRequired(TRUE);
$fields['inspected'] = BaseFieldDefinition::create('timestamp')
->setLabel(t('Inspection date'));
return $fields;
}
}Base fields defined this way live in dedicated table columns rather than the per-field tables that configurable fields create — which matters when you're querying tens of thousands of rows. Querying uses EntityQuery, run in a custom module or drush php context:
$ids = \Drupal::entityQuery('inspection')
->condition('status', 'fail')
->condition('inspected', strtotime('-30 days'), '>=')
->accessCheck(FALSE)
->execute();
$inspections = \Drupal::entityTypeManager()
->getStorage('inspection')
->loadMultiple($ids);Note the explicit accessCheck(FALSE): on modern Drupal versions (9.2+), entity queries check access by default, and omitting the call either filters results unexpectedly or throws, depending on version. Set it deliberately in either direction.
Trade-offs and limits
The custom entity path costs you real things. You write your own list builder, forms, access handler, and routes — or you get a bare-bones admin experience. Views support requires a views data handler annotation. And if the requirements drift toward "actually, editors do need to fix typos in these records," you may end up rebuilding editorial UI that nodes give you free. I've seen projects start with a custom entity for purity and migrate back to nodes once the client asked for revisions and moderation.
Also, a Drupal 7 caveat: the Entity API described here is the Drupal 8+ architecture. D7's entity system is a different, weaker beast, and none of this code ports directly.
Verify before you commit
After defining the entity type and running drush updatedb (or drush entity:updates on older versions) to install the schema, confirm it registered: drush entity:info should list your entity type ID. Then a quick smoke test in drush php — create, save, reload, assert a field value — catches annotation typos that otherwise surface only as a WSOD. If the schema installs cleanly and a round-trip save works, the foundation is sound; everything else is forms and polish.
The actionable takeaway: next time the field-UI temptation strikes, ask who owns the data. If the answer is "a system, not a person," write the entity class. If it's "an editor with deadlines," make the content type and move on with your sprint.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.