Skip to content

How-to guideFor module developersDrupal 11

Create, load, and query entities

Almost everything in Drupal is an entity: nodes, users, taxonomy terms, media, files, your own types. This page covers the daily verbs: create an entity in code, load it, read and change its fields, delete it, and find entities by their field values with entity queries.

Code that manipulates content through the Entity API so that hooks fire, caches invalidate, and revisions and translations behave, regardless of entity type. If you are defining a new kind of data rather than working with existing kinds, start at Define a custom entity type. And if you find yourself writing SQL against node__* tables, stop: the storage layout is an implementation detail, and writing to it directly bypasses every guarantee this page relies on.

  1. Get the storage handler for the entity type. All CRUD goes through storage handlers obtained from \Drupal\Core\Entity\EntityTypeManagerInterface:

    // Fragment: $this->entityTypeManager is injected.
    $storage = $this->entityTypeManager->getStorage('node');

    The string is the entity type ID: node, user, taxonomy_term, media, or your own. Everything below is identical across types; only the field names differ per type and bundle.

  2. Create, load, update, and delete. The four operations in one injected service class:

    web/modules/custom/docs_snippets/src/ArticleManager.php
    <?php
    declare(strict_types=1);
    namespace Drupal\docs_snippets;
    use Drupal\Core\Entity\EntityTypeManagerInterface;
    use Drupal\node\NodeInterface;
    /**
    * Creates, reads, updates and deletes article nodes.
    *
    * All content entities share this API: swap 'node' for 'taxonomy_term',
    * 'media' or your own entity type and the same patterns apply.
    */
    class ArticleManager {
    /**
    * Constructs the manager.
    *
    * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
    * The entity type manager.
    */
    public function __construct(
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    ) {}
    /**
    * Creates and saves a new article.
    *
    * @param string $title
    * The article title.
    * @param string $body
    * The body text.
    * @param int $uid
    * The author's user ID.
    *
    * @return \Drupal\node\NodeInterface
    * The saved node.
    */
    public function createArticle(string $title, string $body, int $uid): NodeInterface {
    /** @var \Drupal\node\NodeInterface $node */
    $node = $this->entityTypeManager->getStorage('node')->create([
    'type' => 'article',
    'title' => $title,
    'body' => [
    'value' => $body,
    'format' => 'basic_html',
    ],
    'uid' => $uid,
    ]);
    // create() only builds the object in memory; save() writes it.
    $node->save();
    return $node;
    }
    /**
    * Loads an article by ID.
    *
    * @param int $nid
    * The node ID.
    *
    * @return \Drupal\node\NodeInterface|null
    * The node, or NULL if it does not exist or is not an article.
    */
    public function loadArticle(int $nid): ?NodeInterface {
    $node = $this->entityTypeManager->getStorage('node')->load($nid);
    if ($node instanceof NodeInterface && $node->bundle() === 'article') {
    return $node;
    }
    return NULL;
    }
    /**
    * Retitles an article.
    *
    * @param int $nid
    * The node ID.
    * @param string $title
    * The new title.
    */
    public function retitleArticle(int $nid, string $title): void {
    $node = $this->loadArticle($nid);
    if ($node) {
    $node->setTitle($title);
    $node->save();
    }
    }
    /**
    * Deletes articles in bulk.
    *
    * @param int[] $nids
    * The node IDs to delete.
    */
    public function deleteArticles(array $nids): void {
    $storage = $this->entityTypeManager->getStorage('node');
    $storage->delete($storage->loadMultiple($nids));
    }
    }

    Two behaviors to internalize: create() only builds an object in memory, and nothing exists until save(); and for bundled entity types, the bundle key (type for nodes) is required at creation and immutable afterward. load() returns the entity or NULL, so guard before use.

  3. Read and write field values. Every field is a \Drupal\Core\Field\FieldItemListInterface (a list, because most fields can hold multiple values), and each item has one or more properties:

    // Fragment: $node is a loaded node entity.
    $title = $node->getTitle();
    $body_text = $node->get('body')->value;
    $summary = $node->get('body')->summary;
    $first_tag_id = $node->get('field_tags')->target_id;

    ->value (or any property name) on the list reads that property from the first item; reference fields use ->target_id for the ID and ->entity for the loaded target. For multi-value reference fields, referencedEntities() loads them all. Writing mirrors reading, and nothing persists until save():

    // Fragment: $node is a loaded node entity.
    $node->setTitle('Updated title');
    $node->set('field_tags', [12, 34]);
    $node->save();
  4. Find entities with entity queries. A query returns entity IDs that match field conditions; you then loadMultiple() the ones you need:

    web/modules/custom/docs_snippets/src/ArticleRepository.php
    <?php
    declare(strict_types=1);
    namespace Drupal\docs_snippets;
    use Drupal\Core\Entity\EntityTypeManagerInterface;
    use Drupal\node\NodeInterface;
    /**
    * Finds articles with entity queries.
    *
    * Entity queries search entities by field values and return IDs; they are
    * storage-agnostic and respect the entity access system when asked to.
    */
    class ArticleRepository {
    /**
    * Constructs the repository.
    *
    * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
    * The entity type manager.
    */
    public function __construct(
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    ) {}
    /**
    * Loads the newest published articles by one author.
    *
    * @param int $uid
    * The author's user ID.
    * @param int $limit
    * How many articles to return at most.
    *
    * @return \Drupal\node\NodeInterface[]
    * The matching nodes, newest first.
    */
    public function findRecentByAuthor(int $uid, int $limit = 10): array {
    $storage = $this->entityTypeManager->getStorage('node');
    $ids = $storage->getQuery()
    ->condition('type', 'article')
    ->condition('status', NodeInterface::PUBLISHED)
    ->condition('uid', $uid)
    ->sort('created', 'DESC')
    ->range(0, $limit)
    // Every entity query must choose: filter out entities the current
    // user may not view (TRUE) or return everything (FALSE). Use FALSE
    // only for trusted, non-display purposes.
    ->accessCheck(TRUE)
    ->execute();
    /** @var \Drupal\node\NodeInterface[] $nodes */
    $nodes = $storage->loadMultiple($ids);
    return $nodes;
    }
    /**
    * Counts published articles tagged with a term.
    *
    * @param int $tid
    * The taxonomy term ID.
    *
    * @return int
    * The number of matching articles.
    */
    public function countByTag(int $tid): int {
    return (int) $this->entityTypeManager->getStorage('node')->getQuery()
    ->condition('type', 'article')
    ->condition('status', NodeInterface::PUBLISHED)
    ->condition('field_tags.target_id', $tid)
    ->accessCheck(TRUE)
    ->count()
    ->execute();
    }
    }

    Every entity query in Drupal 11 must call accessCheck(); \Drupal\Core\Entity\Query\QueryInterface requires the choice to be explicit, and a query that omits it throws QueryException at execute time. accessCheck(TRUE) filters out entities the current user may not view and is right for anything user-facing. accessCheck(FALSE) returns everything and belongs in trusted, non-display contexts: cron, migrations, administrative counts. Note the dotted syntax for conditions on reference fields (field_tags.target_id).

  5. React to CRUD from other modules. Storage fires hooks around every operation, in pairs: a type-specific hook (hook_ENTITY_TYPE_presave(), for nodes node_presave) then the generic one (hook_entity_presave()). The full sequence per operation is in the entity CRUD topic. Implement them in your #[Hook] class:

    web/modules/custom/event_rsvp/src/Hook/EventRsvpHooks.php
    <?php
    declare(strict_types=1);
    namespace Drupal\event_rsvp\Hook;
    use Drupal\Core\Hook\Attribute\Hook;
    use Drupal\event_rsvp\RsvpStorage;
    use Drupal\node\NodeInterface;
    /**
    * Hook implementations for the Event RSVP module.
    *
    * Since Drupal 11.1, hooks live in autowired classes with the #[Hook]
    * attribute instead of procedural functions in the .module file.
    */
    class EventRsvpHooks {
    /**
    * Constructs the hook implementation class.
    *
    * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage
    * The RSVP storage service.
    */
    public function __construct(
    protected readonly RsvpStorage $rsvpStorage,
    ) {}
    /**
    * Implements hook_ENTITY_TYPE_delete() for node entities.
    */
    #[Hook('node_delete')]
    public function nodeDelete(NodeInterface $node): void {
    // Don't leave orphaned RSVP records behind when an event is deleted.
    $this->rsvpStorage->deleteForNode((int) $node->id());
    }
    }

    presave is the place to compute derived values before they hit storage; insert/update/delete run after the database write, for reacting to what happened.

Run each piece interactively against your site:

Terminal window
ddev drush php:eval "
\$node = \Drupal::entityTypeManager()->getStorage('node')
->create(['type' => 'article', 'title' => 'From code']);
\$node->save();
print \$node->id();
"

Then confirm the article appears under Administer → Content/admin/content, and that an entity query finds it: swap the body for print count(\Drupal::entityQuery('node')->condition('type', 'article')->accessCheck(FALSE)->execute());.

For simple exact-match lookups, storage handlers offer a shortcut that skips the query builder: $storage->loadByProperties(['type' => 'article', 'status' => 1]) returns loaded entities directly. Note that it performs no access checking, so treat its results like an accessCheck(FALSE) query.

End a query with ->count()->execute() to get a number instead of IDs, as countByTag() above does. Never load entities only to count() them.

Load in batches and save in a loop; there is no bulk update API that fires hooks correctly. For large sets, do the work in a queue or batch so requests stay fast; see Background processing.

Revisionable types version their data: $storage->loadRevision($vid) fetches a specific revision, and queries can target all revisions with allRevisions() or the newest with latestRevision(). Translated values live on translation objects ($node->getTranslation('fi')), not on extra entities. The model behind both is described in the entity CRUD topic.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.