Drupal: Database API and Entity Queries

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Almost every page of a Drupal site is the result of a query. This page covers the three ways a custom module asks the database for data and the one way it creates its own tables: entity queries (find entity IDs through the Entity API), the Database API (SQL through a Connection object, either as a hand-written static query or as a dynamic query builder), and hook_schema() (declare a custom table so that Drupal creates, tracks and removes it with the module). It also explains which of these to use for which job, why accessCheck() is mandatory on entity queries, how transactions group writes, and what changes for code that serialises query objects in Drupal 12.

It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, API reference and change records on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming events. New sites start on Drupal 11.4.

The running example is the Meridian Museum, a fictitious regional science museum. Its custom module meridian_collection (see Module anatomy and info.yml) defines the content entity type collection_object with the fields accession_number, period, material, dimensions and on_display (see Custom entity types). This page adds a finder service that lists the objects currently on display, a sync-log table that records every run of the collections-management synchronisation, and the repository class that writes to it. The same service classes use the dependency-injection style from Services and dependency injection, and the log table is filled by the queue worker of Queues, batches and cron.

Choosing the right API

Drupal offers several layers over the same database, and picking the highest layer that does the job keeps code portable, cached and access-aware:

flowchart TD Q[What do you need?] --> E{Content or config<br/>entities?} E -- "Load, save or find entities" --> EQ[Entity storage and<br/>entity queries] E -- "Config values" --> CF[Config API:<br/>config.factory] E -- "Own tabular data" --> T{Needs revisions, translations,<br/>Views, JSON:API, permissions?} T -- Yes --> CE[Custom content entity type] T -- No --> DB[Custom table with hook_schema<br/>and the Database API] EQ --> ID[IDs, then loadMultiple] DB --> DYN[Dynamic query for variable SQL] DB --> STA[Static query for fixed SQL]

The decision rules used in the rest of the page:

  • Entities that exist in the Entity API (nodes, users, media, terms, collection_object) are searched with an entity query and loaded through entity storage. The query is storage-agnostic, honours field storage layout, translations and revisions, and the loaded entities pass through hooks, caching and access control. The official documentation advises against talking to the database directly unless the code is part of a core API.

  • Configuration is read and written with the Config API, never with SQL (see Configuration API and schema).

  • Operational data that is not content (a synchronisation log, counters, a lookup cache) can live in a custom table that the module owns, written through the Database API.

  • Content people edit, translate, moderate, expose through Views or the API, or protect with permissions belongs in an entity type, not in a custom table.

Functions that older tutorials use, such as db_query(), db_select(), db_insert() and db_update(), were deprecated in Drupal 8 and removed in Drupal 9. A modern class never calls them, and it does not call \Drupal::database() either: it receives the database service through its constructor.

Entity queries

An entity query answers the question "which entities match these conditions?" and returns identifiers, not entities. It is created from the storage handler of an entity type, which is obtained from the entity_type.manager service:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;

/**
 * Finds collection objects for the public gallery pages.
 */
final class OnDisplayFinder {

  private readonly EntityStorageInterface $storage;

  public function __construct(EntityTypeManagerInterface $entityTypeManager) {
    $this->storage = $entityTypeManager->getStorage('collection_object');
  }

  /**
   * Loads the objects shown in a gallery, ordered by accession number.
   *
   * @return \Drupal\meridian_collection\Entity\CollectionObject[]
   *   The matching entities, keyed by entity ID.
   */
  public function findForGallery(string $gallery_id, int $limit = 24): array {
    $ids = $this->storage->getQuery()
      ->accessCheck(TRUE)
      ->condition('on_display', TRUE)
      ->condition('gallery', $gallery_id)
      ->sort('accession_number', 'ASC')
      ->range(0, $limit)
      ->execute();

    return $this->storage->loadMultiple($ids);
  }

}

The matching service definition uses autowiring, so the constructor type hint is enough:

services:
  _defaults:
    autowire: true
  Drupal\meridian_collection\OnDisplayFinder: ~

accessCheck() is mandatory

accessCheck() tells the query whether it should drop results that the current user is not allowed to view. It has been mandatory since Drupal 10.0. The change record Access checking must be explicitly specified on content entity queries deprecated the implicit behaviour in 9.2 and announced an exception in 10, and a query that is executed without the call throws a QueryException with the message that entity queries must explicitly set whether the query should be access checked or not. Tutorials and modules written for Drupal 8 or early 9 omit the call; they fail on every supported release.

The two arguments mean different things and must be chosen deliberately:

  • accessCheck(TRUE) is the right default whenever the result is shown to a visitor, an editor or an API client. For content entities with SQL storage, the query adds the access conditions of the entity type (for nodes, published status and the node-access grants) and results the user may not view disappear.

  • accessCheck(FALSE) is correct only where the code acts for the system rather than for a person: a queue worker, a Drush command, an update hook, a cron job. The caller then owns the authorisation decision. Using FALSE in a controller or block to "make a query work" can leak unpublished or restricted objects, which is a security defect and not a performance tweak.

Core applies the check only to content entities with SQL storage; for other storage the argument is accepted and has no effect on the results, so the access decision still has to be made when the entities are loaded or displayed. For Meridian, objects that are not yet approved for display are visible only to curators (see Access control and permissions in code), and the gallery finder above keeps accessCheck(TRUE) so that rule applies without any extra code.

Conditions, operators and groups

condition($field, $value = NULL, $operator = NULL, $langcode = NULL) filters on a base field, on a configurable field column (field_name.column, with .value or .target_id for the common cases) or on a field of a referenced entity (field_gallery.entity.name). The operators that every storage understands are =, <>, >, >=, <, <=, IN, NOT IN, BETWEEN, STARTS_WITH, CONTAINS, ENDS_WITH, IS NULL and IS NOT NULL. exists() and notExists() test that a field has a value.

Several conditions are combined with AND by default. An OR is expressed with a condition group:

public function search(string $term, int $limit = 20): array {
  $query = $this->storage->getQuery()->accessCheck(TRUE);

  $or = $query->orConditionGroup()
    ->condition('title', $term, 'CONTAINS')
    ->condition('accession_number', $term, 'STARTS_WITH');

  $ids = $query
    ->condition($or)
    ->condition('on_display', TRUE)
    ->sort('changed', 'DESC')
    ->range(0, $limit)
    ->execute();

  return $this->storage->loadMultiple($ids);
}

Source: QueryInterface

Counting, paging, revisions and aggregation

  • count() turns the query into a count and execute() then returns an integer, which is the cheap way to show "238 objects on display" without loading anything.

  • range($start, $length) limits the result; pager($limit) and tableSort() integrate with the pager and sortable table headers of the admin UI.

  • currentRevision(), latestRevision() and allRevisions() select which revision the query looks at. For a revisionable entity type, execute() returns an array keyed by revision ID with entity IDs as values, so loadMultiple() works on the values and code must not assume the keys are entity IDs. This matters for Meridian’s moderated exhibition content (see Content moderation, workflows and revisions).

  • getAggregateQuery() returns an aggregate query that supports aggregate(), groupBy() and conditionAggregate(), for example the number of objects per period:

public function countByPeriod(): array {
  $rows = $this->storage->getAggregateQuery()
    ->accessCheck(TRUE)
    ->aggregate('id', 'COUNT')
    ->groupBy('period')
    ->execute();

  $counts = [];
  foreach ($rows as $row) {
    $counts[$row['period']] = (int) $row['id_count'];
  }
  return $counts;
}

Translations

By default a query matches entities in any language and may return the same entity ID once for every translation that satisfies a condition. Passing a $langcode to condition(), or filtering on langcode for the translation language, restricts the match to one translation. Load the entity and call getTranslation() or use EntityRepositoryInterface::getTranslationFromContext() when the page is shown in Spanish (see Multilingual sites).

Entity queries versus loading everything

Two patterns appear in older code and both are expensive. Calling loadMultiple() with no IDs loads every entity of the type into memory, and looping over all IDs to filter in PHP does the filtering the database should do. Put the filter in the query, page or chunk the IDs for background jobs, and for very large sets process them in a queue so that the memory is released between runs.

The Database API

The Database API is the layer below entity storage. It is a vendor-agnostic abstraction over MySQL, MariaDB, PostgreSQL and SQLite (core ships those drivers; contributed modules add others), it adds the table prefix, escapes values, lets other modules alter queries and supports transactions. Its central object is the Drupal\Core\Database\Connection, available as the database service. Core registers an autowiring alias for the class, so a constructor parameter typed Connection receives it.

Static queries

A static query is SQL that is passed to the database almost verbatim through Connection::query():

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Database\Connection;

/**
 * Reads the synchronisation log.
 */
final class SyncLogReader {

  public function __construct(private readonly Connection $database) {}

  /**
   * Returns the latest log rows for the given statuses.
   *
   * @param string[] $statuses
   *   Statuses such as "ok" or "failed".
   */
  public function latest(array $statuses, int $since): array {
    $result = $this->database->query(
      'SELECT id, accession_number, status, message, created
       FROM {meridian_collection_sync_log}
       WHERE status IN (:statuses[]) AND created >= :since
       ORDER BY created DESC',
      [':statuses[]' => $statuses, ':since' => $since],
    );
    return $result->fetchAll();
  }

}

Source: Static queries

The rules that make static queries safe and portable:

  • Table names go in curly braces, {meridian_collection_sync_log}. Drupal replaces them with the real, possibly prefixed table name. Without the braces the query breaks as soon as a prefix is configured and, in tests, touches the host site’s data.

  • Values are never concatenated into the SQL. They are passed as placeholders whose names start with a colon (:since) and are bound by the driver, which is what prevents SQL injection. Placeholders must not be quoted or escaped in the SQL: WHERE status = ':status' is wrong and WHERE status = :status is right.

  • An array value expands for IN. A placeholder written :statuses[] with an array value becomes a comma-separated list of individual placeholders. An empty array produces invalid SQL, so guard against it.

  • Placeholders cannot stand for table or column names. If an identifier must come from outside, pass it through $database->escapeTable() or $database->escapeField() and prefer a whitelist.

  • A LIKE pattern built from user input must escape the wildcard characters first with $database->escapeLike().

  • The SQL has to run on every database the site may use. Vendor-specific functions break portability, and Drupal provides Connection::condition(), Select::addExpression() and the functions listed on the Functions and operators page for the common cases.

The result of query() is a statement that can be iterated, or consumed with fetch(), fetchObject(), fetchAssoc(), fetchAll(), fetchAllKeyed(), fetchCol() or fetchField(). By default each row is a stdClass object; the Fetching into a custom class page shows how to hydrate your own value objects.

Static queries return the columns in the order written in the SELECT, and they are the faster choice when the SQL is fixed. They cannot be altered by other modules, which is the main reason to prefer a dynamic query.

Dynamic queries

A dynamic query is built with an object, so parts can vary, can be added conditionally and can be altered by other modules. select($table, $alias) starts it, fields() or addField() is required, and execute() runs it:

public function failuresWithObjects(int $since, int $limit = 50): array {
  $query = $this->database->select('meridian_collection_sync_log', 'l');
  $query->join('collection_object_field_data', 'o', 'o.accession_number = l.accession_number');
  $query
    ->fields('l', ['id', 'accession_number', 'message', 'created'])
    ->fields('o', ['id'])
    ->condition('l.status', 'failed')
    ->condition('l.created', $since, '>=')
    ->orderBy('l.created', 'DESC')
    ->range(0, $limit)
    ->addTag('meridian_collection_sync_log');

  return $query->execute()->fetchAllAssoc('id');
}

The builder provides join(), innerJoin(), leftJoin() and rightJoin(), addExpression() for computed columns, groupBy(), having(), distinct(), orderBy(), orderRandom(), range(), countQuery() and union(). Values in conditions become placeholders automatically, so the builder is injection-safe as long as identifiers (field names, operators, sort directions) do not come from user input. Some practical notes:

  • fetchField(), fetchAllKeyed() and fetchCol() use numeric column positions. Expressions are always added after fields, so do not depend on a particular order; name the columns with fetchAllAssoc() and an alias instead.

  • Print the query with (string) $query and the bound values with $query->arguments() while debugging.

  • ->addTag('name') marks the query. Other modules then alter it with hook_query_alter() or hook_query_TAG_alter(), for example to add access conditions. A dynamic query that joins entity tables for visitors should carry the access tag of that entity type so node-access modules apply their grants, which an entity query with accessCheck(TRUE) does for you.

  • For a paged listing, extend the query with the pager extender and limit(): $query->extend(PagerSelectExtender::class)->limit(25).

use Drupal\Core\Database\Query\PagerSelectExtender;

$query = $this->database->select('meridian_collection_sync_log', 'l')
  ->extend(PagerSelectExtender::class);
$rows = $query
  ->fields('l')
  ->orderBy('l.created', 'DESC')
  ->limit(25)
  ->execute()
  ->fetchAll();

Source: Dynamic queries

A static query is preferred when performance is critical and the SQL never varies; a dynamic query is preferred when parts of the query vary, when other modules must be able to alter it, or when an extender is needed. The official guidance for simple writes is to use the dedicated builders below instead of a hand-written INSERT, UPDATE or DELETE.

Writing: insert, update, upsert, merge and delete

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Database\Connection;

/**
 * Stores the outcome of each object synchronised from the collections API.
 */
final class SyncLogRepository {

  private const TABLE = 'meridian_collection_sync_log';

  public function __construct(
    private readonly Connection $database,
    private readonly TimeInterface $time,
  ) {}

  public function record(string $accession_number, string $status, string $message = ''): int {
    return (int) $this->database->insert(self::TABLE)
      ->fields([
        'accession_number' => $accession_number,
        'status' => $status,
        'message' => $message,
        'created' => $this->time->getRequestTime(),
      ])
      ->execute();
  }

  public function markReviewed(string $accession_number): int {
    return $this->database->update(self::TABLE)
      ->fields(['status' => 'reviewed'])
      ->condition('accession_number', $accession_number)
      ->condition('status', 'failed')
      ->execute();
  }

  public function purgeOlderThan(int $timestamp): int {
    return $this->database->delete(self::TABLE)
      ->condition('created', $timestamp, '<')
      ->execute();
  }

}

Source: Insert queries

  • insert()->fields([...])->execute() returns the last insert ID for a table with a serial column. Calling values() repeatedly inserts several rows in one statement.

  • update() and delete() return the number of affected rows. Without a condition() they change every row, so a forgotten condition is a data-loss bug.

  • upsert($table)->key('column') inserts a row or updates it when the key exists, in a single statement where the driver supports it. merge($table)->keys([...])->fields([...]) expresses the same idea with explicit keys and updateFields()/expression() for the update branch. Both need a unique key or primary key on the key columns.

  • truncate() empties a table and is not transactional on every driver.

Source: Update queries

The service definition injects both dependencies by type:

services:
  _defaults:
    autowire: true
  Drupal\meridian_collection\SyncLogRepository: ~
  Drupal\meridian_collection\SyncLogReader: ~

Transactions

A transaction groups writes so that they all succeed or none does. The official page uses the example of saving a media item and then a node that references it, so that a failure does not leave an orphan. The pattern for Meridian is a batch of log rows written together:

public function recordBatch(array $results): void {
  $transaction = $this->database->startTransaction();
  try {
    foreach ($results as $accession_number => $outcome) {
      $this->record($accession_number, $outcome['status'], $outcome['message'] ?? '');
    }
  }
  catch (\Exception $e) {
    $transaction->rollBack();
    throw $e;
  }
  // The transaction commits when $transaction goes out of scope.
}

The rules that cause most mistakes:

  • The return value of startTransaction() must be assigned to a variable. If it is not, the transaction object is destroyed at once and the transaction is committed immediately, so nothing is protected.

  • The transaction is committed when the variable is unset or goes out of scope at the end of the method. Nothing else commits it.

  • Roll back with $transaction->rollBack(), which targets that transaction by name. The connection’s own rollBack() is for the default savepoint name and misbehaves with nested transactions.

  • Drupal counts nested transactions and commits only when the outermost object goes out of scope, so a service may open its own transaction safely inside a caller’s. Entity storage saves already use transactions internally.

  • Starting a transaction while one is open behaves differently by driver: MySQL implicitly commits the open one, and PostgreSQL only emits a warning, so do not rely on that behaviour.

  • Transactions require engine support. MySQL and MariaDB tables must use InnoDB, which is the default.

  • A transaction protects database rows only. It does not undo calls to an HTTP API, mail, files or cache invalidation, so keep those outside the protected block or make them idempotent.

Where the connection comes from

Classes receive Connection by injection. Procedural code, such as .install files, may use \Drupal::database(), and update hooks conventionally use Database::getConnection() or \Drupal::database()->schema(). The connection details live in $databases in settings.php, read from environment variables so that no credentials are committed (see settings.php and environments); tests and multisite installs rely on the table prefix, which is why braces are required in static queries.

Errors

Database failures throw exceptions from Drupal\Core\Database, for example DatabaseExceptionWrapper for a query that fails at the server, IntegrityConstraintViolationException for duplicate keys and InvalidQueryException for a malformed builder call. Catch the specific exception when there is a sensible recovery, log it through the logger channel otherwise (see Logging and troubleshooting), and never print the SQL or the exception message to visitors.

Source: Error handling

Custom tables with hook_schema()

hook_schema() returns the definition of the tables a module owns. Drupal creates them when the module is installed and drops them when it is uninstalled. It is implemented in the module’s MODULE.install file as a procedural function, because it runs at install time, before OOP hook classes can be relied on, and so does not take a #[Hook] attribute (see Hooks and OOP hooks for the list of hooks that stay procedural). The interface is documented in database.api.php in the Drupal API reference.

<?php

/**
 * @file
 * Install, update and uninstall functions for Meridian Collection.
 */

declare(strict_types=1);

/**
 * Implements hook_schema().
 */
function meridian_collection_schema(): array {
  $schema['meridian_collection_sync_log'] = [
    'description' => 'One row per object synchronised from the collections API.',
    'fields' => [
      'id' => [
        'description' => 'Primary key.',
        'type' => 'serial',
        'unsigned' => TRUE,
        'not null' => TRUE,
      ],
      'accession_number' => [
        'description' => 'Accession number of the museum object.',
        'type' => 'varchar',
        'length' => 64,
        'not null' => TRUE,
        'default' => '',
      ],
      'status' => [
        'description' => 'Outcome: ok, failed or reviewed.',
        'type' => 'varchar',
        'length' => 16,
        'not null' => TRUE,
        'default' => 'ok',
      ],
      'message' => [
        'description' => 'Error or informational message.',
        'type' => 'text',
        'size' => 'normal',
      ],
      'created' => [
        'description' => 'Unix timestamp of the run.',
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 0,
      ],
    ],
    'primary key' => ['id'],
    'indexes' => [
      'accession_number' => ['accession_number'],
      'status_created' => ['status', 'created'],
    ],
  ];

  return $schema;
}

Source: hook_schema()

The structure of a table definition:

  • description is documentation shown by tools; write it for every table and column.

  • fields maps column names to a specification with type (serial, int, float, numeric, varchar, char, text, blob, datetime), size (tiny, small, medium, normal, big), length, unsigned, not null, default and, for numerics, precision and scale. Drupal translates the generic type into the right type for the active driver, which is why the Schema API is used instead of raw CREATE TABLE.

  • primary key, unique keys and indexes list columns. Name indexes after what they serve; the database may limit the length of identifiers, so keep table and column names short, and prefix the table with the module name so that the name cannot collide with another module’s.

  • foreign keys are documentation only. Drupal does not create or enforce foreign keys, so referential integrity is the module’s responsibility.

  • The tables created by hook_schema() are removed automatically on uninstall; there is no need to drop them in hook_uninstall(). Data that must be kept longer belongs elsewhere.

Changing a table after release

hook_schema() is read only when the module is installed. Changing it later affects new sites only, so every change to an existing table needs a matching hook_update_N() in the same .install file. Copy the table definition into the update rather than calling hook_schema() again, because the schema function will change in later releases and would make old updates wrong:

/**
 * Adds a duration column and an index to the sync log.
 */
function meridian_collection_update_11001(): void {
  $schema = \Drupal::database()->schema();

  if (!$schema->fieldExists('meridian_collection_sync_log', 'duration_ms')) {
    $schema->addField('meridian_collection_sync_log', 'duration_ms', [
      'description' => 'Duration of the call in milliseconds.',
      'type' => 'int',
      'unsigned' => TRUE,
      'not null' => TRUE,
      'default' => 0,
    ]);
  }
}

Update numbers follow the pattern core major version, schema level and a counter (for example 11001), and an update must be safe to run twice. Schema provides createTable(), dropTable(), addField(), dropField(), changeField(), addIndex(), addUniqueKey(), addPrimaryKey() and the existence checks tableExists(), fieldExists() and indexExists(). Run the updates with drush updatedb, or drush deploy in a pipeline (see Update API and post-update functions). Note that the schema of entity types is not handled this way: changes to entity fields go through the entity definition update manager, as covered in Custom entity types.

Views and other integrations

A custom table is invisible to Views, the REST and JSON:API modules, translation, revisions, access checks, caching of loaded entities and configuration management. Exposing the sync log in an admin view means implementing a views-data hook and describing the table to Views; exposing it through an API means writing a controller. If a requirement for any of those appears, that is the signal to promote the data to an entity type.

When to use a custom table

Use a custom table when all of the following are true:

  • the data is operational (logs, counters, sync state, queue-like work lists, import mappings, lookup tables) rather than content someone edits;

  • it is written in high volume or in bulk, so the per-row cost of an entity (field storage, hooks, revisions, cache tags) would hurt;

  • it needs no translation, revisions, workflow, per-item permissions, editorial form or API exposure;

  • it is private to the module and does not need to be exported with configuration.

Use an entity type when people create or edit the records, when the records must be translated, moderated or shown by Views, or when other modules should be able to extend them with fields. Use state or configuration for a handful of values: the state service for per-environment runtime values and the Config API for settings that are exported. Meridian’s collection_object is therefore an entity (curators edit, translate and display it), while the sync log is a table (the queue worker writes thousands of rows that nobody edits and that can be purged after 90 days).

Security and performance

  • SQL injection is prevented by placeholders and the query builders. It reappears whenever a value, a field name or an operator is concatenated into SQL, or when a placeholder is quoted. Treat identifiers from request data as unsafe.

  • Access bypass. accessCheck(FALSE) and direct SQL on entity tables skip entity access and node-access grants. Reserve them for system code, and never reuse such a query’s result in a visitor-facing response without filtering.

  • Direct SQL on entity tables (for example collection_object_field_data) depends on the storage layout, which changes with the field configuration, with translations and revisions, and bypasses validation, hooks, cache invalidation and search indexing. Use an entity query and entity storage for entities; the join in the example above is a read-only report on a table whose layout the same module controls.

  • Indexes. Every WHERE, JOIN and ORDER BY column in a frequently executed query should be covered by an index declared in the schema; verify with EXPLAIN on the real database. Over-indexing slows writes.

  • Unbounded results. Always page or range() listings and chunk background jobs.

  • Caching. A query result shown on a page needs cache metadata, such as a custom cache tag invalidated when the log changes (see Performance and caching).

  • Tests. Kernel tests install a custom table with installSchema('meridian_collection', ['meridian_collection_sync_log']) and run against the test database prefix, so the same code and braces work there (see Automated testing).

Version notes and Drupal 12

  • Entity queries have required accessCheck() since Drupal 10.0, as described above. Upgrade Status reports queries that lack it.

  • Query serialisation. Database query objects (Select, Insert, Update and the other classes derived from Query) hold a reference to the database connection. Serialising them, for example by keeping a Select in a property of a class that ends up in the form cache, a batch operation or a queue item, is deprecated from Drupal 11.5.0 (the next minor, due in December 2026) and removed in Drupal 12.0.0, and in the development branch the class throws a LogicException saying that database query objects are not serializable. The fix is to store what is needed to rebuild the query (arguments, entity IDs, filter values) or its results, and to build the query again when it is needed. Services that keep a connection should use DependencySerializationTrait. Plan this in the same release as the other preparations for Drupal 12.

  • Plugin and service style. Examples on this page use constructor injection, autowiring and PHP 8 constructor promotion, as Drupal core does. Procedural \Drupal:: calls are limited to .install code.

  • hook_schema() and hook_update_N() stay procedural in Drupal 11.x; the other OOP-hook rules from 11.1 do not apply to them.

  • Drupal 10 reaches end of life on 2026-12-09; the code on this page needs no change when moving from 10.6 to 11.3 or later, apart from the removal of deprecated calls reported by Drupal Rector.

Corrections to older books and tutorials

Older material is correct about the concepts (entity queries return IDs, placeholders prevent injection, transactions need a variable) and outdated in the details:

  • Books and tutorials from the Drupal 8 and 9 era show \Drupal::entityQuery('node') and getQuery() without accessCheck(). That has thrown an exception since Drupal 10.0 and must be written with an explicit TRUE or FALSE.

  • Examples that use db_query(), db_select() or db_insert() belong to Drupal 7 or early Drupal 8 and no longer exist.

  • Drupal 10 Development Cookbook (Glaman and Quillen, 2023) is written for Drupal 10.0 and for hooks in .module files with \Drupal:: calls inside classes. Since 11.1 hooks are written as #[Hook] methods on autowired classes, and classes receive Connection and EntityTypeManagerInterface through the constructor; hook_schema() is one of the few hooks that stays procedural.

  • Examples written for Drupal 10 may keep a Select object in a form-state or batch property. That worked until query serialisation was deprecated in 11.5; it is removed in 12.