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:
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);
}
}
Source: Working with the Entity API
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. UsingFALSEin 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 andexecute()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)andtableSort()integrate with the pager and sortable table headers of the admin UI. -
currentRevision(),latestRevision()andallRevisions()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, soloadMultiple()works on the values and code must not assume the keys are entity IDs. This matters for Meridian’s moderatedexhibitioncontent (see Content moderation, workflows and revisions). -
getAggregateQuery()returns an aggregate query that supportsaggregate(),groupBy()andconditionAggregate(), 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;
}
Source: Working with the Entity API
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 andWHERE status = :statusis 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
LIKEpattern 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');
}
Source: Introduction to dynamic queries
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()andfetchCol()use numeric column positions. Expressions are always added after fields, so do not depend on a particular order; name the columns withfetchAllAssoc()and an alias instead. -
Print the query with
(string) $queryand the bound values with$query->arguments()while debugging. -
->addTag('name')marks the query. Other modules then alter it withhook_query_alter()orhook_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 withaccessCheck(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 aserialcolumn. Callingvalues()repeatedly inserts several rows in one statement. -
update()anddelete()return the number of affected rows. Without acondition()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 andupdateFields()/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.
}
Source: Database transactions
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 ownrollBack()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:
-
descriptionis documentation shown by tools; write it for every table and column. -
fieldsmaps column names to a specification withtype(serial,int,float,numeric,varchar,char,text,blob,datetime),size(tiny,small,medium,normal,big),length,unsigned,not null,defaultand, for numerics,precisionandscale. Drupal translates the generic type into the right type for the active driver, which is why the Schema API is used instead of rawCREATE TABLE. -
primary key,unique keysandindexeslist 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 keysare 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 inhook_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,JOINandORDER BYcolumn in a frequently executed query should be covered by an index declared in the schema; verify withEXPLAINon 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,Updateand the other classes derived fromQuery) hold a reference to the database connection. Serialising them, for example by keeping aSelectin 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 aLogicExceptionsaying 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 useDependencySerializationTrait. 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.installcode. -
hook_schema()andhook_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')andgetQuery()withoutaccessCheck(). That has thrown an exception since Drupal 10.0 and must be written with an explicitTRUEorFALSE. -
Examples that use
db_query(),db_select()ordb_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
.modulefiles with\Drupal::calls inside classes. Since 11.1 hooks are written as#[Hook]methods on autowired classes, and classes receiveConnectionandEntityTypeManagerInterfacethrough the constructor;hook_schema()is one of the few hooks that stays procedural. -
Examples written for Drupal 10 may keep a
Selectobject in a form-state or batch property. That worked until query serialisation was deprecated in 11.5; it is removed in 12.
References
-
Database API (drupal.org)
-
Database API overview (drupal.org)
-
Static queries (drupal.org)
-
Dynamic queries (drupal.org)
-
Introduction to dynamic queries (drupal.org)
-
Insert queries (drupal.org)
-
Update queries (drupal.org)
-
Database transactions (drupal.org)
-
Error handling (drupal.org)
-
Change record: Core services provide aliases for autowiring (drupal.org)
-
Working with the Entity API (drupal.org)
-
Updating database schema and/or data (drupal.org)
-
Change record: Access checking must be explicitly specified on content entity queries (drupal.org)
-
hook_schema() (api.drupal.org)
-
QueryInterface (api.drupal.org)
-
Query class (api.drupal.org)