Drupalwoo ☉

Building A Custom Drupal Entity Type With Base Fields

Drupal’s entity system provides a dependable way to model structured records that need storage, validation, permissions, revisions, and administrative screens. When a project contains more than ordinary pages or articles, a custom content entity can offer a cleaner solution than adding a large collection of fields to an existing node type.

Base fields are defined in PHP and belong to the entity type itself. They are different from configurable fields created through the Drupal administration interface: base fields are deployed with code, have predictable machine names, and are available consistently across environments.

A useful example is a service request entity for an Australian maintenance business. Each record might contain a customer name, suburb, postcode, preferred visit date, status, and internal notes. The model could support jobs in Sydney, Melbourne, Brisbane, or Perth without forcing editors to manage a complicated node configuration.

This approach suits Drupal websites where developers need repeatable deployments and editors need a focused workflow. It also gives JavaScript, Views, REST, and custom controllers a stable data structure to work with as the project grows.

Define The Entity’s Responsibility

Before creating files, decide what the entity represents and where its lifecycle begins and ends. A service request should describe one request from submission through completion. It should not also become a customer database, an invoice, and a calendar event unless those responsibilities are deliberately part of the design.

Write down the fields, required values, default values, and permitted status changes. For example, a request could begin as “New”, move to “Assigned”, then “In progress”, “Completed”, or “Cancelled”. Defining this vocabulary early prevents different developers from using values such as done, finished, and complete for the same state.

Consider the needs of the local business as well. A contractor serving Melbourne’s inner suburbs may need a suburb and postcode, while a regional Queensland operator may need additional travel information. An Australian postcode should be stored as text rather than an integer, because validation rules and display formatting are clearer when the value is treated as a string.

Create The Custom Module

A custom entity belongs in a custom module. A practical module name for this example is service_request, with files placed under web/modules/custom/service_request in a Composer-based Drupal installation.

Create the module definition first:

# service_request.info.yml
name: Service Request
type: module
description: Provides a service request content entity.
package: Custom
core_version_requirement: ^10.3 || ^11
dependencies:
  - drupal:field
  - drupal:options

The field dependency is useful when the entity exposes base fields through Field UI, while options supports list-style fields in many Drupal installations. Enable the module with Drush after the entity class and annotation are ready. On a development site, drush en service_request -y is convenient; production deployments should enable the module through the project’s normal configuration and release process.

Keep the module’s namespace aligned with its machine name. The entity class will normally live at src/Entity/ServiceRequest.php, under the namespace Drupal\service_request\Entity. A typo in the namespace or file path can cause the entity plugin discovery process to fail.

Register The Content Entity

A content entity extends ContentEntityBase and uses the ContentEntityType annotation. The annotation tells Drupal how to store the entity, which handlers to use, and which routes should be generated.

<?php

namespace Drupal\service_request\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;

/**
 * Defines the service request entity.
 *
 * @ContentEntityType(
 *   id = "service_request",
 *   label = @Translation("Service request"),
 *   handlers = {
 *     "list_builder" = "Drupal\service_request\ServiceRequestListBuilder",
 *     "form" = {
 *       "add" = "Drupal\service_request\Form\ServiceRequestForm",
 *       "edit" = "Drupal\service_request\Form\ServiceRequestForm",
 *       "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm"
 *     },
 *     "access" = "Drupal\service_request\ServiceRequestAccessControlHandler"
 *   },
 *   base_table = "service_request",
 *   admin_permission = "administer service requests",
 *   entity_keys = {
 *     "id" = "id",
 *     "uuid" = "uuid",
 *     "label" = "title",
 *     "owner" = "uid"
 *   },
 *   links = {
 *     "canonical" = "/service-request/{service_request}",
 *     "add-form" = "/service-request/add",
 *     "edit-form" = "/service-request/{service_request}/edit",
 *     "delete-form" = "/service-request/{service_request}/delete",
 *     "collection" = "/admin/content/service-requests"
 *   },
 *   field_ui_base_route = "entity.service_request.settings"
 * )
 */
class ServiceRequest extends ContentEntityBase {

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

}

The list builder, form, access handler, and settings route must exist before all administrative links work. During an initial build, you can use the core content entity handlers where appropriate, but explicit handlers provide better control over labels, permissions, and validation.

Drupal versions also support PHP attributes for entity definitions, and new codebases may choose them instead of annotations. Follow the conventions already used by the project. Consistency is more valuable than converting one entity while the rest of the module uses another discovery style.

Declare Base Fields

The baseFieldDefinitions() method is where the entity’s built-in schema is described. Every field needs a type, label, and storage definition. The following example includes a title, owner, status, location, and notes.

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

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

  $fields['uid'] = BaseFieldDefinition::create('entity_reference')
    ->setLabel(t('Created by'))
    ->setSetting('target_type', 'user')
    ->setDefaultValueCallback(static::class . '::getDefaultOwner')
    ->setDisplayOptions('form', [
      'type' => 'entity_reference_autocomplete',
      'weight' => 0,
    ])
    ->setDisplayConfigurable('form', TRUE);

  $fields['status'] = BaseFieldDefinition::create('list_string')
    ->setLabel(t('Status'))
    ->setRequired(TRUE)
    ->setSetting('allowed_values', [
      'new' => 'New',
      'assigned' => 'Assigned',
      'progress' => 'In progress',
      'completed' => 'Completed',
      'cancelled' => 'Cancelled',
    ])
    ->setDefaultValue('new')
    ->setDisplayOptions('form', [
      'type' => 'options_select',
      'weight' => 5,
    ]);

  $fields['postcode'] = BaseFieldDefinition::create('string')
    ->setLabel(t('Postcode'))
    ->setSetting('max_length', 4)
    ->setDisplayOptions('form', [
      'type' => 'string_textfield',
      'weight' => 10,
    ]);

  $fields['notes'] = BaseFieldDefinition::create('text_long')
    ->setLabel(t('Notes'))
    ->setDisplayOptions('form', [
      'type' => 'text_textarea',
      'weight' => 20,
    ]);

  return $fields;
}

A base field’s type affects storage, widgets, Views integration, and validation. Use string for short identifiers, text_long for substantial text, datetime for dates, boolean for flags, decimal for financial values, and entity_reference for relationships. An amount such as a quoted price should use decimal, with an explicitly chosen scale and precision, rather than a floating-point number.

Add Defaults And Validation

Default values can be static or calculated. The owner field commonly defaults to the current user:

public static function getDefaultOwner() {
  return [\Drupal::currentUser()->id()];
}

For more testable code, inject services into forms and handlers rather than calling the service container throughout the entity class. A default callback is acceptable for simple values, but business rules such as assigning a request to a regional team should generally live in a dedicated service.

Validation belongs close to the field definition when it describes the field itself. A four-digit postcode, for example, can use a Range constraint. A title can use Length, and an email field can use the email data type or an appropriate constraint. Do not rely solely on a JavaScript form restriction, because imports, REST requests, and command-line operations can bypass the browser.

For Australian operations, a date field should be stored in a timezone-aware way and displayed according to the site’s configured timezone. A team scheduling work in AEST, ACST, or AWST may need the exact local time of a visit, especially when jobs span Queensland, South Australia, and Western Australia. Store the canonical value consistently, then format it for the user’s context.

Build Forms And Administrative Routes

The entity annotation references forms, but those forms should extend ContentEntityForm so Drupal can handle entity form state and field widgets correctly.

namespace Drupal\service_request\Form;

use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Form\FormStateInterface;

class ServiceRequestForm extends ContentEntityForm {

  public function save(array $form, FormStateInterface $form_state) {
    $entity = $this->getEntity();
    $status = $entity->save();

    $message = $status === SAVED_NEW
      ? $this->t('The service request has been created.')
      : $this->t('The service request has been updated.');

    $this->messenger()->addStatus($message);
    $form_state->setRedirect('entity.service_request.collection');
  }

}

The collection route should point to a list builder that extends EntityListBuilder. Its buildHeader() and buildRow() methods can display the title, postcode, status, and changed date. Add filtering later through Views or a custom query if editors need to find jobs by suburb, assigned user, or date range.

Permissions deserve separate attention. The administrative permission in the annotation is intentionally broad, while the access control handler can distinguish viewing, editing, deleting, and creating. A small agency in Sydney may allow coordinators to edit requests but restrict deletion to managers. Avoid granting every editor full entity administration simply because the initial prototype has a single user role.

When documenting the implementation for a client or a related project, keep the requirements and acceptance criteria in a clear project reference so developers can compare the entity behaviour with the expected workflow. The entity itself should still enforce important rules independently of that documentation.

Install And Update The Schema

Enabling the module creates the base table and its field storage. If the entity is already installed and you later add or alter base fields, Drupal needs an update hook. Changing the PHP method alone does not update a production database.

Use an update function in service_request.install for schema changes:

/**
 * Adds a suburb field to service requests.
 */
function service_request_update_9001() {
  $definition = \Drupal::service('entity_field.manager')
    ->getFieldStorageDefinitions('service_request')['suburb'];

  \Drupal::entityDefinitionUpdateManager()
    ->installFieldStorageDefinition('suburb', 'service_request', 'service_request', $definition);
}

The exact update code depends on whether the field is new, renamed, or changed. Test updates against a copy of production data, especially when converting field types or reducing maximum lengths. Run drush updb, then rebuild caches and export configuration where the project uses configuration management.

Base fields are code-defined, so teams should review them through version control. A deployment to a Brisbane client’s live site should apply the same update sequence as a deployment to a Melbourne staging environment. Avoid manually creating replacement fields through the UI when the field is intended to be part of the entity’s permanent schema.

Test The Entity In Real Workflows

A custom entity is complete only when its complete workflow works. Test creation, editing, deletion, access permissions, validation errors, default values, list output, and canonical pages. Check that anonymous users cannot access internal records and that an editor cannot change fields outside their role.

Write kernel tests for entity storage and validation, plus functional tests for forms and routes. A useful test can create a service request, assert that its status defaults to new, save it, reload it, and verify the postcode and owner values. Functional JavaScript tests are useful when autocomplete widgets or dynamic field behaviour are part of the interface.

Inspect the entity with Drush and Views as well. Confirm that base fields appear with sensible labels, that status values are human-readable, and that empty fields do not produce confusing output. If the site sells services around the Australian market, test postcodes such as 2000 for Sydney, 3000 for Melbourne, and 4000 for Brisbane, while ensuring leading zeroes are preserved for regions that use them.

Finally, consider operational details such as revisions, moderation, translations, caching, and personal information. Customer names, addresses, and notes may fall under privacy obligations, so limit permissions and avoid exposing them in public APIs by accident. A carefully defined content entity gives Drupal a strong foundation, but its schema, access rules, and deployment process must all reflect the real work editors perform.