So far this topic has been about a single, isolated setting — one message, one textarea. But what if you need something more like a mini content type of your own: a whole collection of records, each with an ID, a label, and custom fields, that site administrators can create, edit, and delete through an admin UI, and that you can still export cleanly to YAML? That's exactly the problem config entities solve, and this lesson introduces them through a delightfully silly real example from Drupal's own Examples project: robots.
What you'll learn in this lesson
- What a config entity is, and how it differs from the simple key/value config you just met
- How the
@ConfigEntityTypeannotation registers an entire entity type with Drupal - What each of the annotation's required keys actually controls
- How one YAML file gets written to disk per record, making config entities fully version-controllable
The source file
Path (relative to the Examples module's root): modules/config_entity_example/src/Entity/Robot.php
<?php
namespace Drupal\config_entity_example\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
/**
* Defines the robot entity.
*
* The lines below, starting with '@ConfigEntityType,' are a plugin annotation.
* These define the entity type to the entity type manager.
*
* The properties in the annotation are as follows:
* - id: The machine name of the entity type.
* - label: The human-readable label of the entity type. We pass this through
* the "@Translation" wrapper so that the multilingual system may
* translate it in the user interface.
* - handlers: An array of entity handler classes, keyed by handler type.
* - access: The class that is used for access checks.
* - list_builder: The class that provides listings of the entity.
* - form: An array of entity form classes keyed by their operation.
* - entity_keys: Specifies the class properties in which unique keys are
* stored for this entity type. Unique keys are properties which you know
* will be unique, and which the entity manager can use as unique in database
* queries.
* - links: entity URL definitions. These are mostly used for Field UI.
* Arbitrary keys can set here. For example, User sets cancel-form, while
* Node uses delete-form.
*
* @see http://previousnext.com.au/blog/understanding-drupal-8s-config-entities
* @see annotation
* @see Drupal\Core\Annotation\Translation
*
* @ingroup config_entity_example
*
* @ConfigEntityType(
* id = "robot",
* label = @Translation("Robot"),
* admin_permission = "administer robots",
* handlers = {
* "access" = "Drupal\config_entity_example\RobotAccessController",
* "list_builder" = "Drupal\config_entity_example\Controller\RobotListBuilder",
* "form" = {
* "add" = "Drupal\config_entity_example\Form\RobotAddForm",
* "edit" = "Drupal\config_entity_example\Form\RobotEditForm",
* "delete" = "Drupal\config_entity_example\Form\RobotDeleteForm"
* }
* },
* entity_keys = {
* "id" = "id",
* "label" = "label"
* },
* links = {
* "edit-form" = "/examples/config_entity_example/manage/{robot}",
* "delete-form" = "/examples/config_entity_example/manage/{robot}/delete"
* },
* config_export = {
* "id",
* "uuid",
* "label",
* "neural_system"
* }
* )
*/
class Robot extends ConfigEntityBase {
/**
* The robot ID.
*
* @var string
*/
public $id;
/**
* The robot UUID.
*
* @var string
*/
public $uuid;
/**
* The robot label.
*
* @var string
*/
public $label;
/**
* The robot neural_system flag.
*
* @var string
*/
public $neural_system;
}
How it works
A plugin annotation instead of a hook
Notice there's no hook_entity_type_build() or service registration anywhere. Instead, everything between @ConfigEntityType( and its closing ) is a specially formatted PHP docblock called a plugin annotation. Drupal's entity type manager scans for these at build time (and caches the result), which is how the entire "robot" entity type gets registered just by this class existing in the right namespace.
id and label
id = "robot",
label = @Translation("Robot"),
id is the machine name for this entire entity type (not one record — the type itself). It becomes the config file prefix (robot.{entity_id}.yml), the {robot} token you'll see in route paths, and the string you'd pass to \Drupal::entityTypeManager()->getStorage('robot') if you needed to load robots programmatically. label is wrapped in @Translation() — the same translation wrapper pattern you've seen on $this->t() calls in earlier lessons, just usable inside an annotation instead of a method body.
admin_permission
admin_permission = "administer robots",
A single fallback permission string, declared separately in config_entity_example.permissions.yml. When no more specific access logic applies, Drupal checks whether the current user holds this permission before allowing any operation on a robot. Config entities typically need much simpler access rules than content entities, since they're usually managed only by trusted site builders.
The handlers key — one class per responsibility
handlers = {
"access" = "Drupal\config_entity_example\RobotAccessController",
"list_builder" = "Drupal\config_entity_example\Controller\RobotListBuilder",
"form" = {
"add" = "Drupal\config_entity_example\Form\RobotAddForm",
"edit" = "Drupal\config_entity_example\Form\RobotEditForm",
"delete" = "Drupal\config_entity_example\Form\RobotDeleteForm"
}
},
This is the part that does the real heavy lifting, and it's worth slowing down on. Each key names a class Drupal delegates a specific job to:
access— every time code calls$entity->access('view')or similar, Drupal hands the decision to this class.list_builder— extendsConfigEntityListBuilderand suppliesbuildHeader()andbuildRow(), which is exactly what renders the admin listing table you'll see in a moment.form— three separate classes, one per CRUD operation:RobotAddFormfor creating,RobotEditFormfor modifying an existing robot, andRobotDeleteFormas a confirmation step before deletion.
Each of these is independently swappable — a module that wants to alter only the delete confirmation, for instance, can override just that one handler without touching the others.
entity_keys — telling Drupal which property is which
entity_keys = {
"id" = "id",
"label" = "label"
},
This map tells the entity system which PHP class property holds the machine ID and which holds the human-readable label. Without it, methods like $entity->id() and $entity->label() — both inherited for free from ConfigEntityBase — wouldn't know where to look.
links — named URLs with automatic entity upcasting
links = {
"edit-form" = "/examples/config_entity_example/manage/{robot}",
"delete-form" = "/examples/config_entity_example/manage/{robot}/delete"
}
The {robot} token here is an entity upcasting placeholder: Drupal's router automatically loads the specific Robot entity whose ID matches that path segment and hands it straight to your controller or form — you never write the lookup code yourself. Once this is declared, calling $entity->toUrl('edit-form')->toString() anywhere gives you the correct URL without ever hardcoding a path.
config_export — what actually gets written to YAML
config_export = {
"id",
"uuid",
"label",
"neural_system"
}
Only properties listed here get serialized when a robot is exported (for example via drush cex). Every config entity should list id and uuid at minimum; any custom property — here, neural_system — must be added explicitly, or it silently won't travel through the configuration management pipeline at all.
The class body: intentionally almost empty
class Robot extends ConfigEntityBase {
public $id;
public $uuid;
public $label;
public $neural_system;
}
ConfigEntityBase handles all the loading, saving, deleting, and serializing logic for you. All that's left for this class to do is declare its data properties as plain public class members — one per piece of information a robot stores. There are no getters or setters to write; ConfigEntityBase exposes them directly.
What lands on disk
When a robot with id = "r2d2" is saved, Drupal writes a file at config/sync/robot.r2d2.yml containing exactly the properties named in config_export:
id: r2d2
uuid: 1234-5678-...
label: 'R2-D2'
neural_system: '1'
That's the whole payoff of choosing a config entity: every robot is fully portable, committable to version control, and deployable to another environment with drush cim — no custom export tooling required.
See it for yourself
Visit /examples/config-entity-example on your own DDEV site.
You should see at least one robot already present — Marvin, the paranoid android — created automatically the moment the module was installed, plus Edit and Delete operations next to it. That listing table is entirely generated by the list_builder handler you just read about; try adding a second robot of your own and watch it appear in the same table.
That's the RobotAddForm from the form handlers block, rendered at the entity.robot.add_form route. Notice it only asks for exactly the two properties this lesson's Robot class declares as public members — label and neural_system — and that Drupal auto-derives the machine name (r2d2) from the label as you type, the same slugify behavior you'd expect from any other machine-name field in Drupal.
Quick check: if you added a custom property called
manufacturerto theRobotclass but forgot to add"manufacturer"to theconfig_exportlist, what would happen when you randrush cex? If you said "it would be silently left out of the exported YAML," you've got the key detail.
Key takeaways
- Config entities extend
ConfigEntityBaseand are defined by a@ConfigEntityTypeannotation — no hook or service registration needed. - The
handlerskey wires up separate, independently swappable classes for access control, the admin listing, and each CRUD form operation. - The
entity_keysmap tells the entity system which class properties hold the machine ID and human label, powering$entity->id()and$entity->label(). - The
linkskey defines named URL templates with{entity_id}upcasting tokens — use$entity->toUrl('edit-form')instead of hardcoding paths. - Only properties listed in
config_exportget written to YAML during export — every custom property you add must be listed there explicitly. - Each record becomes its own
entity_type.machine_id.ymlfile in the config sync directory, making config entities version-controllable and deployable across environments.
Coming up next
You've defined the shape of a config entity — now let's look at the piece that makes Drupal actually trust and validate that shape: the config schema file. It's what turns a plain YAML mapping into a properly typed, translatable, export-safe structure.