Drupal Entity API: Creating a Custom Content Entity in Drupal 11

The Drupal Entity API is the backbone of every non-trivial Drupal project. Nodes, users, taxonomy terms, and media items are all content entities. When you need structured data that does not map neatly to a Node — say, a service ticket, a product, an event log entry — you build a custom content entity. In Drupal 11 with PHP attributes replacing annotations, the developer experience is cleaner and more IDE-friendly than ever.

This guide builds a complete, working EventLog entity from scratch: PHP attribute definition, base fields, field UI support, list builder, form handlers, and route scaffolding.

When to Build a Custom Entity vs Using a Node

  • You need a different storage table or tighter schema control.
  • The data is not editorial content (no publishing workflow, no revisions needed).
  • You want a clean REST/JSON:API surface without node-specific baggage.
  • You need programmatic access patterns that node's field API makes expensive.

Module Scaffold

modules/custom/event_log/
├── event_log.info.yml
├── event_log.module
├── event_log.routing.yml
├── event_log.links.menu.yml
├── event_log.links.action.yml
└── src/
    └── Entity/
        └── EventLog.php
        └── EventLogInterface.php
    └── EventLogListBuilder.php

event_log.info.yml

name: 'Event Log'
type: module
description: 'Tracks application events as a custom content entity.'
package: Custom
core_version_requirement: ^11

Defining the Entity with PHP Attributes

Drupal 11.1+ supports #[ContentEntityType] as a native PHP 8 attribute. No more docblock annotations — your IDE can navigate to the attribute class directly.

<?php

namespace Drupal\event_log\Entity;

use Drupal\Core\Entity\Attribute\ContentEntityType;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\EntityOwnerTrait;

#[ContentEntityType(
  id: 'event_log',
  label: new TranslatableMarkup('Event Log'),
  label_collection: new TranslatableMarkup('Event Logs'),
  label_singular: new TranslatableMarkup('event log entry'),
  label_plural: new TranslatableMarkup('event log entries'),
  label_count: [
    'singular' => '@count event log entry',
    'plural'   => '@count event log entries',
  ],
  handlers: [
    'list_builder' => 'Drupal\event_log\EventLogListBuilder',
    'form' => [
      'add'    => 'Drupal\Core\Entity\ContentEntityForm',
      'edit'   => 'Drupal\Core\Entity\ContentEntityForm',
      'delete' => 'Drupal\Core\Entity\ContentEntityDeleteForm',
    ],
    'route_provider' => [
      'html' => 'Drupal\Core\Entity\Routing\AdminHtmlRouteProvider',
    ],
  ],
  base_table: 'event_log',
  admin_permission: 'administer event log',
  entity_keys: [
    'id'    => 'id',
    'uuid'  => 'uuid',
    'owner' => 'uid',
    'label' => 'title',
  ],
  links: [
    'canonical'   => '/admin/content/event-log/{event_log}',
    'add-form'    => '/admin/content/event-log/add',
    'edit-form'   => '/admin/content/event-log/{event_log}/edit',
    'delete-form' => '/admin/content/event-log/{event_log}/delete',
    'collection'  => '/admin/content/event-log',
  ],
)]
class EventLog extends ContentEntityBase implements EventLogInterface
{
    use EntityChangedTrait;
    use EntityOwnerTrait;

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

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

        $fields['severity'] = BaseFieldDefinition::create('list_string')
            ->setLabel(new TranslatableMarkup('Severity'))
            ->setRequired(TRUE)
            ->setSetting('allowed_values', [
                'info'    => 'Info',
                'warning' => 'Warning',
                'error'   => 'Error',
            ])
            ->setDefaultValue('info')
            ->setDisplayOptions('form', [
                'type'   => 'options_select',
                'weight' => -8,
            ])
            ->setDisplayConfigurable('form', TRUE)
            ->setDisplayConfigurable('view', TRUE);

        $fields['message'] = BaseFieldDefinition::create('text_long')
            ->setLabel(new TranslatableMarkup('Message'))
            ->setDisplayOptions('form', [
                'type'   => 'text_textarea',
                'weight' => -6,
            ])
            ->setDisplayConfigurable('form', TRUE)
            ->setDisplayConfigurable('view', TRUE);

        $fields['source_ip'] = BaseFieldDefinition::create('string')
            ->setLabel(new TranslatableMarkup('Source IP'))
            ->setSetting('max_length', 45) // IPv6 max length
            ->setDisplayOptions('view', [
                'label'  => 'inline',
                'weight' => 0,
            ])
            ->setDisplayConfigurable('view', TRUE);

        $fields['created'] = BaseFieldDefinition::create('created')
            ->setLabel(new TranslatableMarkup('Created'))
            ->setDisplayConfigurable('view', TRUE);

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

        return $fields;
    }
}

The Interface

<?php

namespace Drupal\event_log\Entity;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\user\EntityOwnerInterface;

interface EventLogInterface extends
    ContentEntityInterface,
    EntityChangedInterface,
    EntityOwnerInterface
{
    public function getTitle(): string;
    public function setTitle(string $title): static;
    public function getSeverity(): string;
    public function setSeverity(string $severity): static;
    public function getMessage(): string;
    public function setMessage(string $message): static;
}

Add the implementations to EventLog.php:

    public function getTitle(): string
    {
        return $this->get('title')->value ?? '';
    }

    public function setTitle(string $title): static
    {
        $this->set('title', $title);
        return $this;
    }

    public function getSeverity(): string
    {
        return $this->get('severity')->value ?? 'info';
    }

    public function setSeverity(string $severity): static
    {
        $this->set('severity', $severity);
        return $this;
    }

    public function getMessage(): string
    {
        return $this->get('message')->value ?? '';
    }

    public function setMessage(string $message): static
    {
        $this->set('message', $message);
        return $this;
    }

List Builder

<?php

namespace Drupal\event_log;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;

class EventLogListBuilder extends EntityListBuilder
{
    public function buildHeader(): array
    {
        return [
            'id'       => $this->t('ID'),
            'title'    => $this->t('Title'),
            'severity' => $this->t('Severity'),
            'created'  => $this->t('Created'),
        ] + parent::buildHeader();
    }

    public function buildRow(EntityInterface $entity): array
    {
        /** @var \Drupal\event_log\Entity\EventLogInterface $entity */
        return [
            'id'       => $entity->id(),
            'title'    => $entity->toLink(),
            'severity' => $entity->getSeverity(),
            'created'  => \Drupal::service('date.formatter')
                              ->format($entity->getCreatedTime(), 'short'),
        ] + parent::buildRow($entity);
    }
}

Routing

AdminHtmlRouteProvider (declared in the attribute's handlers) auto-generates routes for the entity links. You still need a collection route if you want a custom admin listing path:

# event_log.routing.yml
entity.event_log.collection:
  path: '/admin/content/event-log'
  defaults:
    _entity_list: 'event_log'
    _title: 'Event Logs'
  requirements:
    _permission: 'administer event log'

Permissions

# event_log.permissions.yml
administer event log:
  title: 'Administer Event Log entries'
  restrict access: true

Programmatic Usage

<?php

use Drupal\event_log\Entity\EventLog;

// Create and save an entry
$log = EventLog::create([
    'title'     => 'User login failed',
    'severity'  => 'warning',
    'message'   => 'Three consecutive failures from ' . $ip,
    'source_ip' => $ip,
    'uid'       => \Drupal::currentUser()->id(),
]);
$log->save();

// Load recent errors
$storage = \Drupal::entityTypeManager()->getStorage('event_log');
$ids = $storage->getQuery()
    ->condition('severity', 'error')
    ->sort('created', 'DESC')
    ->range(0, 25)
    ->accessCheck(TRUE)
    ->execute();

$entries = $storage->loadMultiple($ids);

Enabling and Updating the Schema

# Install the module (creates the table automatically)
drush en event_log -y
drush cr

# After adding new base fields, run entity updates
drush entup

Adding JSON:API Support

Drupal core's JSON:API module exposes all content entities automatically once the module is installed. Your event_log entity is available at /jsonapi/event_log/event_log with no additional configuration. Add the jsonapi_extras module to control field exposure per entity type.

Summary Checklist

  • Use #[ContentEntityType] attribute (not annotations) for Drupal 11.1+.
  • Extend ContentEntityBase; use traits (EntityChangedTrait, EntityOwnerTrait) for common behaviour.
  • Declare all fields in baseFieldDefinitions() with display options for Field UI.
  • Declare handlers in the attribute for list builder, form classes, and route provider.
  • Use AdminHtmlRouteProvider to get CRUD routes generated from the entity links map.
  • Run drush entup after schema changes, not drush updb.
  • JSON:API and REST endpoints are available with no extra code once core modules are enabled.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.