Drupal EntityListBuilder: Building an Admin Table for a Custom Entityfor Drupal 11 , and 10

Last updated :  

This is the fourth and final lesson in the Content Entities topic. You've defined the Contact entity type (Lesson 31), wired up its add/edit/delete routes (Lesson 32), and locked those routes down with proper access control (Lesson 33). There's one job left: showing administrators a real, usable table of every Contact that exists on the site. That's what an EntityListBuilder does — and it's a pattern you'll reuse for every content entity type you ever build.

What you'll learn in this lesson

  • What an EntityListBuilder is, and why nearly every custom entity type needs one
  • How buildHeader() and buildRow() work together to produce a table, one column definition and one row-population method
  • Why list builders use a differently-named factory method, createInstance(), instead of the usual create()
  • How to inject extra page content (like a description paragraph) above the table by overriding render()
  • How Drupal automatically adds an Operations column with working edit/delete links, without you writing that logic yourself

The source file

Path: modules/content_entity_example/src/Entity/Controller/ContactListBuilder.php

<?php

namespace Drupal\content_entity_example\Entity\Controller;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Routing\UrlGeneratorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Provides a list controller for content_entity_example entity.
 *
 * @ingroup content_entity_example
 */
class ContactListBuilder extends EntityListBuilder {

  /**
   * The url generator.
   *
   * @var \Drupal\Core\Routing\UrlGeneratorInterface
   */
  protected $urlGenerator;

  /**
   * {@inheritdoc}
   */
  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
    return new static(
      $entity_type,
      $container->get('entity_type.manager')->getStorage($entity_type->id()),
      $container->get('url_generator')
    );
  }

  /**
   * Constructs a new ContactListBuilder object.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type definition.
   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
   *   The entity storage class.
   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
   *   The url generator.
   */
  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, UrlGeneratorInterface $url_generator) {
    parent::__construct($entity_type, $storage);
    $this->urlGenerator = $url_generator;
  }

  /**
   * {@inheritdoc}
   *
   * We override ::render() so that we can add our own content above the table.
   * parent::render() is where EntityListBuilder creates the table using our
   * buildHeader() and buildRow() implementations.
   */
  public function render() {
    $build['description'] = [
      '#markup' => $this->t('Content Entity Example implements a Contacts model. These contacts are fieldable entities. You can manage the fields on the Contacts admin page.', [
        ':admin_link' => $this->urlGenerator->generateFromRoute('content_entity_example.contact_settings'),
      ]),
    ];
    $build['table'] = parent::render();
    return $build;
  }

  /**
   * {@inheritdoc}
   *
   * Building the header and content lines for the contact list.
   *
   * Calling the parent::buildHeader() adds a column for the possible actions
   * and inserts the 'edit' and 'delete' links as defined for the entity type.
   */
  public function buildHeader() {
    $header['id'] = $this->t('ContactID');
    $header['name'] = $this->t('Name');
    $header['first_name'] = $this->t('First Name');
    $header['role'] = $this->t('Role');
    return $header + parent::buildHeader();
  }

  /**
   * {@inheritdoc}
   */
  public function buildRow(EntityInterface $entity) {
    /** @var \Drupal\content_entity_example\Entity\Contact $entity */
    $row['id'] = $entity->id();
    $row['name'] = $entity->toLink()->toString();
    $row['first_name'] = $entity->first_name->value;
    $row['role'] = $entity->role->value;
    return $row + parent::buildRow($entity);
  }

}

How it works

Namespace and inheritance

The class lives at Drupal\content_entity_example\Entity\Controller\ContactListBuilder, matching its file path src/Entity/Controller/ContactListBuilder.php exactly — Drupal's PSR-4 autoloading depends on that match. It extends Drupal\Core\Entity\EntityListBuilder, which already knows how to load a paged set of entities and assemble a table render array; you only override the parts specific to your entity's columns.

createInstance() — not the create() you might expect

public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
    return new static(
      $entity_type,
      $container->get('entity_type.manager')->getStorage($entity_type->id()),
      $container->get('url_generator')
    );
}
Easy mistake to make: most injectable Drupal classes (controllers, forms, plugins) use a static method literally named create(). List builders are the exception — EntityListBuilder implements ContainerInjectionInterface using the method name createInstance() instead, because the entity-handler system already reserves create() for a different purpose. Name it wrong and Drupal will silently fall back to defaults instead of your custom logic.

Inside, two services are pulled from the container: the storage handler for this specific entity type (via entity_type.manager), and url_generator, a service that turns route names into path strings without hard-coding URLs. Using new static(...) rather than new self(...) means a subclass calling this factory would correctly get an instance of itself, not hard-coded to ContactListBuilder.

Constructor: storing the extra dependency

public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, UrlGeneratorInterface $url_generator) {
    parent::__construct($entity_type, $storage);
    $this->urlGenerator = $url_generator;
}

The parent constructor already knows what to do with $entity_type and $storage — both are required by every list builder. The only custom step here is saving $url_generator onto a protected property so render() can use it later.

Overriding render() to add page content

public function render() {
    $build['description'] = [
      '#markup' => $this->t('Content Entity Example implements a Contacts model. These contacts are fieldable entities. You can manage the fields on the Contacts admin page.', [
        ':admin_link' => $this->urlGenerator->generateFromRoute('content_entity_example.contact_settings'),
      ]),
    ];
    $build['table'] = parent::render();
    return $build;
}

By default, parent::render() returns only the table itself. This override wraps that in a bigger render array with a description element placed first — an introductory paragraph with a working link, built from $this->t() (translation-safe) and the injected URL generator (no hard-coded paths). The :admin_link placeholder's colon prefix tells t() to treat the value as a URL and HTML-escape it automatically — a small but important XSS safeguard baked into the translation system itself.

Defining columns with buildHeader()

public function buildHeader() {
    $header['id'] = $this->t('ContactID');
    $header['name'] = $this->t('Name');
    $header['first_name'] = $this->t('First Name');
    $header['role'] = $this->t('Role');
    return $header + parent::buildHeader();
}

Each array key here becomes a column machine name, and each value is the translated label shown in the table header. These exact keys — id, name, first_name, role — must reappear as keys in buildRow(), or Drupal won't know which cell belongs under which header. The + parent::buildHeader() at the end merges in a final Operations column supplied by the base class — you never have to build that column yourself.

Populating each row with buildRow()

public function buildRow(EntityInterface $entity) {
    /** @var \Drupal\content_entity_example\Entity\Contact $entity */
    $row['id'] = $entity->id();
    $row['name'] = $entity->toLink()->toString();
    $row['first_name'] = $entity->first_name->value;
    $row['role'] = $entity->role->value;
    return $row + parent::buildRow($entity);
}

This method runs once per loaded entity. Two access patterns worth noting:

  • $entity->toLink()->toString() — builds a Link object pointing at the entity's own canonical (view) route, then renders it straight to an HTML string, giving the Name column a working clickable link with zero manual URL-building.
  • $entity->first_name->value — reads the raw scalar value out of a field using Drupal's typed data API. Every field on an entity is itself an object; ->value unwraps it down to the plain PHP value for that field's main property.

Just like the header, $row + parent::buildRow($entity) appends the Operations cell (the actual edit/delete link markup) supplied by the parent class for this specific entity.

What the base class quietly handles for you

None of this is visible in the file above, but it's worth knowing it's there: EntityListBuilder::load() queries storage for every entity of this type, respects each one's access control (the handler from the previous lesson!), and applies a default 50-per-page limit with a pager. getOperations() collects the edit/delete links from the entity type's links annotation. And the parent render() assembles everything into a proper #type => 'table' render array, pager included. You're only responsible for the columns — the plumbing is already built.

See it for yourself

Visit /content_entity_example_contact/list on your DDEV site.

The Contact list admin page showing a table with ContactID, Name, First Name, Role, and Operations columns

That's the description paragraph from the overridden render(), followed by a real table with the exact columns from buildHeader() — ContactID, Name (a working link, courtesy of toLink()), First Name, Role — plus an Operations column Drupal built for free. Every visible piece of this page traces back to a specific method in the roughly 80-line class you just read.

Quick check: if you added a fifth column to buildHeader() but forgot to add a matching key to buildRow(), what would you expect to see? The header shows the new column label, but every row's cell under it is simply missing — Drupal has no data to align with that key, so nothing gets rendered there.

Key takeaways

  • Extend EntityListBuilder and place the class under src/Entity/Controller/ to follow Drupal's naming conventions for entity list controllers.
  • List builders use createInstance(), not create(), as their dependency-injection factory method — this is a real gotcha worth remembering.
  • buildHeader() and buildRow() must use matching array keys so Drupal can align each row's cells with the correct column.
  • $row + parent::buildRow($entity) and $header + parent::buildHeader() are how you keep the automatic Operations column (edit/delete links) without building it yourself.
  • Override render() only when you need extra page content around the table — delegate the table itself back to parent::render() rather than reimplementing it.
  • Register your list builder in the entity type's annotation with "list_builder" = "Drupal\your_module\...\YourListBuilder" so Drupal instantiates it automatically for the entity's collection route.

Coming up next

That completes the Content Entities topic — you can now define a fully custom entity type, give it real CRUD routes, lock it down with proper access control, and list every instance in a clean admin table. The next topic, Field API, builds directly on this: instead of hard-coded base fields like first_name and role, you'll learn to define entirely new, reusable field types that any entity — content entity or node — can use.