Drupal: Drush

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.

Drush is the command-line tool of Drupal. Almost everything an administrator does in the browser (clearing caches, installing a module, importing configuration, running database updates, creating a user, reading the log) has a Drush command, and the commands can be scripted, repeated and run against another server over SSH. A deployment pipeline is mostly a sequence of Drush commands, a developer’s day is full of drush cr and drush cex, and a custom module can add its own commands to the same tool. This page explains how Drush and Drupal versions fit together, how to install Drush in a project, the 25 commands used most often, how site aliases address local and remote sites, and how to write a command of your own.

It was written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official Drush documentation on 2026-10-30. Drupal 10 reaches end of life on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07 (see Versions, release cycle and support and Preparing for Drupal 12). Examples use the fictitious Meridian Museum site, its meridian_collection module and its meridian_events recipe, and run through DDEV (ddev drush ...); without DDEV, replace ddev drush with vendor/bin/drush.

Drush and Drupal versions

Drush has its own release lines, and each line is built for a range of Drupal versions and a minimum PHP version. Using a Drush that is too old or too new for the site is the most common cause of a command that fails at start-up before it does anything. The compatibility table in the official installation page lists, for the lines still relevant today:

Drush PHP Drupal versions

14

8.3 or later

Drupal 11.3 and later, and Drupal 12

13

8.3 or later

Drupal 10.2 and later, and Drupal 11

12

8.1 or later

Listed as compatible, but not marked as a supported and recommended line

Drush 11 and older are listed as compatible with older Drupal versions but no longer supported, and Drush 8 (the last line for Drupal 7) reached its end of life in January 2025. The page gives "to be determined" as the end-of-life date of Drush 12, 13 and 14. In practice:

  • a Drupal 10.2 or 10.3 site uses Drush 13, a Drupal 11.0, 11.1 or 11.2 site uses Drush 13, and a site on Drupal 11.3 or 11.4 can use either 13 or 14;

  • Drush 14 is the line that continues to Drupal 12; the Drush 13.7 release notes state that the core commands are converted to native Symfony Console commands in the 14.x branch (see Writing a custom command);

  • the Composer constraint of drush/drush decides which line a project gets, so a composer require drush/drush on a Drupal 11.4 site picks the newest compatible release, and composer.lock freezes it;

  • a Drush older than the site’s Drupal fails with an error about the container or about a missing class, because the Drupal kernel it expects has changed; upgrading Drush with composer require drush/drush:^13 (or ^14) fixes it.

Older books pin Drush to the Drupal version of their time. Drupal 10 Masterclass (2023) is written for Drush 12, and Drupal 10 Development Cookbook (2023) shows commands that Drush 12 and earlier read from docblock annotations (@command, @option) and a drush.services.yml file. The commands in those books still work (drush cr, drush cex, drush uli, drush deploy are unchanged), but the way to write a command, covered below, is different since Drush 12 and 13, and a Drush 12 installed by following those books is no longer a supported line.

Installing Drush in a project

The official documentation supports one installation method: Composer, with Drush as a dependency of the Drupal project. The Drupal project created by composer create-project drupal/recommended-project does not contain Drush, so it is added once:

cd meridian-museum
ddev composer require drush/drush
ddev drush status

Source: Drush: Install

composer require drush/drush adds the package to composer.json, installs it into vendor/ and creates the launcher vendor/bin/drush. Because Drush is in composer.lock, every developer, the CI job and the production deployment run exactly the same Drush. That is why the documentation insists on a per-project install and does not recommend a global one:

  • a global Drush would be one version for many sites with different Drupal versions;

  • Drush loads the site’s own vendor/autoload.php, so it needs the code of the project it works on (a remote Drush must be installed in the remote project too, because an alias runs vendor/bin/drush there);

  • a project that needs a development-only tool can still put Drush in require-dev, but a deployment that runs drush deploy on the server then needs composer install without --no-dev, so most projects keep Drush in require.

The launcher is run from the project root as vendor/bin/drush. If you only work on one codebase, add its vendor/bin directory to the PATH, and then drush works from any folder. Inside DDEV, ddev drush runs the project’s Drush inside the web container, with the right PHP, database host and environment variables, so it is the recommended way on a workstation (see Installation with Composer and DDEV). To use a different PHP than the default, put the wanted PHP first in the PATH; to change PHP settings such as memory_limit, use a custom php.ini for Drush, which sub-processes such as updatedb also inherit.

Two optional conveniences:

# Shell completion for bash, zsh or fish; read the help for the line to add to the shell profile.
vendor/bin/drush completion --help

# Show which Drush, PHP, database and paths a site uses; the first command to run when something is wrong.
vendor/bin/drush core:status
vendor/bin/drush core:status --field=drush-version

Drush needs a Drupal site that can be bootstrapped to run most commands. It finds the site by walking up from the current directory to the Composer project root, and then picks the site directory (web/sites/default) from the --uri option or from the uri of the alias. A fresh clone without a settings.php or database can still run the commands that do not need a database, such as drush generate, drush help and drush site:install.

flowchart TD A[drush command] --> B{Command found?} B -->|core command| C[Drush core, vendor/drush/drush] B -->|module command| D[src/Drush/Commands of an enabled module] B -->|site-wide command| E[drush/Commands in the project] C --> F[Bootstrap to the level the command declares] D --> F E --> F F --> G[Read settings.php, connect to the database, load enabled modules] G --> H[Run the command and format the result]

The global options and the help system

Every command accepts the same global options. The ones used constantly:

Option Meaning

-y, --yes

Accepts the default answer to every prompt, the same as --no-interaction. Required in scripts and CI, where there is nobody to answer; dangerous when copied to a terminal on production without reading the prompt first.

-l, --uri=URI

The base URL of the site. It builds absolute links (the one-time login link of drush uli, URLs in queued emails) and selects the site directory in a multisite. The default is https://default, which is why a link printed on a server looks wrong until --uri or an alias supplies the real host.

-v, -vv, -vvv, --verbose

Message verbosity, from normal to debug. -vvv also prints the stack trace of an exception, which is the first thing to add when a command fails with a one-line error.

-s, --simulate

Prints the commands that would be run, for the commands that support it; pm:install and pm:uninstall show the modules that would change.

-r, --root=PATH

The Drupal root, when Drush is not run from inside the project.

drush list shows every command, drush help COMMAND shows the options and examples of one command, and drush topic shows the documentation topics, including the full list of global options. The command reference lives at drush.org (the unversioned address drush.org/latest/ redirects to the 13.x documentation at the date of this page).

The 25 most-used commands

The commands below are grouped by what they do. Each has a short alias (the names printed in drush list); the long name is group:command, such as cache:rebuild, and the alias such as cr is the same command. All aliases and options in this section were taken from the official command reference for Drush 13; they are the same in Drush 14 unless stated.

Command Aliases What it does

cache:rebuild

cr, rebuild, cache-rebuild

Rebuilds the Drupal container, the registries (routes, plugins, hooks) and all caches. The fix for "I added a file and nothing happens".

config:export

cex, config-export

Writes the active configuration to the sync directory (see Configuration management).

config:import

cim, config-import

Makes the active configuration equal to the sync directory.

updatedb

updb

Runs pending hook_update_N() and post-update functions (see Update API and post-updates).

deploy

none listed

Runs the whole post-deployment sequence in one command (drush deploy).

user:login

uli, user-login

Prints a one-time login link, optionally for a given user and path.

pm:install

en, in, install, pm-install, pm-enable, pm:enable

Installs one or more modules (and the modules they depend on).

pm:uninstall

pmu, un, pm-uninstall

Uninstalls modules.

sql:dump

sql-dump

Exports the database with mysqldump, pg_dump or sqlite3.

sql:cli

sqlc, sql-cli

Opens the database client with the site’s credentials.

sql:sanitize

sqlsan, sql-sanitize

Replaces personal data in the database (Sanitising a database).

watchdog:show

ws, wd-show, watchdog-show

Prints the most recent log entries, filtered by type or severity.

state:set

sset, state-set

Writes a value to the State system.

config:get

cget, config-get

Reads a configuration object or one of its keys.

user:create

ucrt, user-create

Creates a user account.

role:perm:add

rap, role-add-perm

Grants permissions to a role.

core:cron

cron, core-cron

Runs cron: every hook_cron() and every queue with a cron definition.

queue:run

queue-run

Processes one queue by name.

recipe:apply

recipe

Applies a recipe (see Drupal Recipes).

generate

gen

Generates module, plugin, service, test and Drush command skeletons.

core:status

status, st, core-status

Shows the versions, paths and database connection Drush found.

config:set

cset, config-set

Writes one key (or a whole object) of the active configuration.

sql:sync

sql-sync

Copies the database from one site alias to another.

php:eval

eval, ev, php-eval

Evaluates a PHP snippet in a bootstrapped site.

maint:set

mset

Turns maintenance mode on (1) or off (0).

Source: Drush: Commands

Caches: cache:rebuild

drush cr is the command that is run after nearly every change of code, YAML or a library definition. It rebuilds the service container, discovers again every hook implementation, route, plugin and Twig template, and empties the render, page and dynamic page caches (see Performance and caching).

ddev drush cache:rebuild
ddev drush cr
# Rebuild without letting Drush clear caches itself; the caller (a deploy script) clears them later.
ddev drush cr --no-cache-clear

The sibling commands are cache:get, cache:set, cache:clear and cache:tags for working on one cache bin or invalidating one tag, and cache:warm (see drush deploy). Pitfalls: drush cr is also the way to recover from a fatal error caused by a stale container after a module was removed from disk. A rebuild on a busy production site makes the next requests slow and can cause a stampede, so it belongs in the deployment sequence and not in a loop.

Configuration: config:export, config:import, config:get, config:set

The configuration commands move settings between the active configuration (in the database) and the sync directory (YAML files in the Git repository). The detailed workflow, config_sync_directory, Config Split and the safe import rules are in Configuration management; the commands themselves:

# Show the whole system.site object, or one key of it.
ddev drush config:get system.site
ddev drush config:get system.site page.front

# Include overrides from settings.php and modules, i.e. the value the site actually uses.
ddev drush config:get system.site name --include-overridden

# Read from the sync directory instead of the active configuration.
ddev drush config:get system.site --source=sync

# Change one value, then export it so that it lands in Git.
ddev drush config:set system.site name "Meridian Museum"
ddev drush config:set system.site page.front /exhibitions
ddev drush config:export --diff
ddev drush cex -y

# Import what is in the sync directory; preview the diff first.
ddev drush config:import --diff
ddev drush cim -y

# Import only new and changed files, without deleting configuration that is missing from the directory.
ddev drush config:import --partial --source=/var/www/html/config/recipes-output

Details worth knowing:

  • config:export also accepts --add (runs git add -p so that you pick the hunks), --commit with --message (commits every exported file) and --destination (writes to another directory, which is a backup directory when no value is given).

  • config:import --partial imports new and updated files but deletes nothing and skips configuration transformations (the mechanism Config Split uses); a normal import makes the active configuration equal to the directory, which deletes any configuration that is missing from it. A sync directory that is empty or from the wrong branch therefore deletes content types and fields on import, which is why the preview and a database backup come first.

  • config:get reads the stored value. Without --include-overridden it does not show values forced from settings.php ($config['system.site']['name'] = ...), so a value that "does not change" is often an override.

  • config:set takes --input-format=yaml to set a list or a map; a key of ? updates several top-level keys at once from a YAML mapping, and - as the value reads it from standard input:

ddev drush config:set --input-format=yaml user.role.authenticated ? "{label: 'Museum member', weight: 5}"
cat site-mail.yml | ddev drush config:set --input-format=yaml system.site ? -

Other commands of the same group are config:status (the differences between the active configuration and the directory, the quick "is the repository in sync" check), config:delete, config:edit and config:pull (copy the configuration of another site to the local sync directory).

Database updates: updatedb

drush updatedb (alias updb) is the command line version of update.php. It runs the pending hook_update_N() functions and post-update functions of every module and, by default, clears caches afterwards (--no-cache-clear suppresses that). Since Drush 13.7.2 the documentation states that updatedb puts the site into maintenance mode while it runs. drush updatedb:status lists what is pending without running it, which is what a pipeline can use to decide whether a deployment needs a maintenance window.

ddev drush updatedb:status
ddev drush updatedb -y

Source: Drush: updatedb

The usual order after new code is deployed is: updatedb, then config:import, then cache:rebuild, which is exactly the sequence of drush deploy.

drush deploy

drush deploy (Drush 10.3 and later) exists so that nobody has to remember that order. After the deployment script has updated the code (git pull or a new image, and composer install --no-dev), one command makes the site match the new code. According to the official "Deploy command for Drupal" topic it runs, in order:

  1. drush updatedb

  2. drush config:import

  3. drush cache:rebuild

  4. drush deploy:hook, which runs the pending deploy hooks

  5. drush cache:warm, only on Drupal 11.2 and later

The three kinds of update function run in this order within one deployment: hook_update_N() first (low-level schema and data changes, where the full API is not available), then hook_post_update_NAME() (still before the configuration import), and finally hook_deploy_NAME(), a Drush-specific hook that runs after the configuration import, when the new fields, vocabularies and permissions already exist. That makes a deploy hook the correct place to create content or fill a new field that the imported configuration introduced, which neither of the Drupal hooks can rely on.

flowchart LR CODE[New code deployed, composer install] --> UPDB[updatedb<br/>hook_update_N and post_update] UPDB --> CIM[config:import] CIM --> CR[cache:rebuild] CR --> HOOK[deploy:hook<br/>hook_deploy_NAME] HOOK --> WARM[cache:warm<br/>Drupal 11.2+]

A deploy hook lives in a file called MODULE.deploy.php in the module folder, and its function name is MODULE_deploy_NAME(). The hook receives a $sandbox array and can return a status message; to process a large amount of data, it sets $sandbox['#finished'] to a value below 1 until it is done, and Drush calls it again, in the same way as an update function. For the Meridian Museum, a new on_display field is added to collection_object by configuration, and a deploy hook fills it for the objects that have a gallery:

<?php

/**
 * @file
 * Deploy hooks of the Meridian collection module.
 */

declare(strict_types=1);

/**
 * Marks every collection object that has a gallery as on display.
 */
function meridian_collection_deploy_backfill_on_display(array &$sandbox): string {
  $storage = \Drupal::entityTypeManager()->getStorage('collection_object');

  if (!isset($sandbox['ids'])) {
    $sandbox['ids'] = $storage->getQuery()
      ->accessCheck(FALSE)
      ->condition('gallery', NULL, 'IS NOT NULL')
      ->execute();
    $sandbox['total'] = count($sandbox['ids']);
  }

  // Process 50 objects per pass so that the memory stays flat.
  $batch = array_splice($sandbox['ids'], 0, 50);
  foreach ($storage->loadMultiple($batch) as $object) {
    $object->set('on_display', TRUE)->save();
  }

  $sandbox['#finished'] = $sandbox['total'] === 0
    ? 1
    : 1 - (count($sandbox['ids']) / $sandbox['total']);

  return sprintf('%d of %d objects processed.', $sandbox['total'] - count($sandbox['ids']), $sandbox['total']);
}

Drush records which deploy hooks have run, so each runs once per database. drush deploy:hook-status lists the pending ones and drush deploy:mark-complete marks them all as done without running them, which is used when a hook has already been applied by hand. A deploy hook that is skipped because the site was deployed with separate updatedb, config:import and cache:rebuild commands is the most common surprise: use drush deploy, not three commands, in every environment.

Options of the individual steps are configured in drush.yml (for example command.config.import.options), because drush deploy itself takes only the global options.

One-time login: user:login

drush uli prints a one-time login link and, unless --no-browser is given, opens it in the default browser. It is how the administrator of a new site gets in, and how a lost password is recovered without email.

ddev drush user:login
ddev drush uli --name=curator --uri=https://meridian.ddev.site
ddev drush uli --uid=2 admin/content
ddev drush uli --mail=editor@meridian.example --no-browser

With no option the link is for user 1, and an optional path argument is where the user lands after login. Pitfalls: a link that points to http://default/... means the URI was not supplied (use --uri, an alias or DRUSH_OPTIONS_URI); the link works once, and a second use or a link older than the expiry shows "You have tried to use a one-time login link that has either been used or is no longer valid"; and the output is a credential, so it must never be pasted into a ticket or chat.

Modules and themes: pm:install, pm:uninstall, theme:install

ddev drush pm:install --simulate content_moderation workflows
ddev drush pm:install meridian_collection -y
ddev drush en views_ui -y
ddev drush pm:uninstall --simulate field_ui
ddev drush pmu field_ui -y
ddev drush pm:list --status=enabled --type=module
ddev drush theme:install meridian

pm:install installs the dependencies listed in the module’s info.yml and enables the module; it does not download it. Downloading is Composer’s job (composer require drupal/webform); the drush dl and drush pm-download commands of Drush 8 no longer exist. The module list is a comma-separated or space-separated list. pm:uninstall refuses to remove a module whose configuration or content other modules depend on, and the --simulate option shows what would be uninstalled first. Because the enabled modules are stored in core.extension, installing a module also changes the exported configuration, so config:export follows it. The older drush dis (pm-disable) disappeared with Drupal 8 because Drupal has no disabled state, only installed and uninstalled.

Databases: sql:dump, sql:cli, sql:sync, sql:sanitize

The commands that export, open and replace the database are described with their backup and restore workflows in Backups and disaster recovery. In short:

# Dump, compressed, to a dated file in ~/drush-backups.
ddev drush sql:dump --gzip --result-file=auto

# Open the SQL client with the credentials of settings.php, run one query, or restore a dump.
ddev drush sql:cli
ddev drush sql:query "SELECT COUNT(*) FROM node_field_data WHERE type = 'exhibition'"
gunzip -c backups/meridian.sql.gz | ddev drush sql:cli

# Copy the production database over the local one (see "Site aliases" below).
ddev drush sql:sync @meridian.prod @self

sql:sync SOURCE TARGET dumps the source database, copies the dump (by rsync) and imports it into the target, replacing what is there. It therefore needs both aliases to be reachable, and the target is destroyed without a second prompt when -y is given. Options include --no-dump and --no-sync to reuse an existing dump, --create-db, the table lists --skip-tables-list, --structure-tables-list and --tables-list, and the matching ...-key options that pick a list from configuration. For a restore, $(drush sql:connect) < dump.sql is more efficient than piping into sql:cli.

Sanitising a database

A production database contains names, email addresses and password hashes of real people. Copying it to a laptop, a staging server or a CI cache moves personal data to places with weaker protection and, under data-protection law, may not be allowed. drush sql:sanitize (alias sqlsan) rewrites that data in the database it is run against:

# After syncing production to a staging copy, sanitise the copy (never the source).
ddev drush sql:sync @meridian.prod @self
ddev drush sql:sanitize --sanitize-email='user+%uid@meridian.invalid' --sanitize-password=no -y

# Keep two fields and users that hold the role administrator.
ddev drush sql:sanitize --allowlist-fields=field_biography,field_phone_number --ignored-roles=administrator

By default passwords are randomised and email addresses become user+%uid@localhost.localdomain; the %uid, %mail and %name placeholders can be used in the pattern. --sanitize-password accepts no or any other string to use as every password (use a throwaway value for a shared development copy, never a real one). --allowlist-fields and --ignored-roles exempt fields and users. The sanitising is extensible: a module can add its own steps by subscribing to the \Drush\Events\SanitizeConfirmsEvent event (the confirmation summary) and Symfony’s ConsoleTerminateEvent (the queries or API calls themselves); the older hook-based approach is deprecated. Sanitising does not cover content that people typed (a comment that mentions a name), private files, webform submissions that a contributed module stores in its own tables, or logs, so an export for outsiders needs a review of those as well.

Logs: watchdog:show and watchdog:tail

If the dblog module is enabled, Drupal writes its log to the database, and Drush reads it without a browser (see Logging, status report and troubleshooting).

ddev drush watchdog:show
ddev drush watchdog:show --count=50 --severity-min=Warning
ddev drush watchdog:show --type=php --extended
ddev drush watchdog:show "meridian_collection" --fields=wid,type,severity,message
ddev drush watchdog:show --filter="type!=locale"
ddev drush watchdog:tail

watchdog:show (aliases ws, wd-show) prints the last 10 entries by default, takes an optional substring, and offers --count, --severity, --severity-min, --type, --extended, --fields, --field and --filter. watchdog:tail follows the log as new entries arrive, watchdog:show-one ID prints one entry in full, and watchdog:delete empties or trims the table. On a site that writes the log to syslog or a file (the syslog module, Monolog) these commands see nothing.

State: state:set, state:get, state:delete

The State system holds values that belong to one environment and are not configuration: the last cron time, the maintenance flag, the Twig debug switches. Drush reads and writes it, including keys no screen shows:

ddev drush state:get system.cron_last
ddev drush state:set meridian_collection.last_sync 0 --input-format=integer
ddev drush state:set twig_debug TRUE
ddev drush state:set twig_cache_disable TRUE
ddev drush state:set disable_rendered_output_cache_bins TRUE
ddev drush state:delete twig_debug

The value is stored as it is typed according to --input-format (string, integer, float, boolean, json, yaml or the default auto), and - reads it from standard input, for a JSON array for instance. The Twig and cache switches shown are the Drupal 10.1 and later way to enable Twig debug, auto reload and disabled render caches without development.services.yml (see Twig templates and debugging). The same switch is in settings.php in the environments that should always have it.

Maintenance mode is stored in State too (system.maintenance_mode), but there is a dedicated command:

ddev drush maint:set 1
ddev drush maint:status && ddev drush core:cron
ddev drush maint:set 0

The core:cron reference suggests the form drush maint:status && drush core:cron so that cron does not run while the site is in maintenance mode, which would poison caches.

Users and roles: user:create, role:perm:add

# The password comes from the environment, not from the command line text.
ddev drush user:create curator.ana --mail=ana@meridian.example --password="$MERIDIAN_NEW_USER_PASSWORD"
ddev drush user:role:add curator curator.ana
ddev drush role:create reviewer "Reviewer"
ddev drush role:perm:add reviewer 'access content overview,view the administration theme'
ddev drush role:perm:add anonymous 'access content'
ddev drush role:list --format=json
ddev drush user:information curator.ana
ddev drush user:password curator.ana "$MERIDIAN_NEW_USER_PASSWORD"
ddev drush user:block curator.ana

user:create (alias ucrt) takes the account name, --mail and --password and can print chosen fields; user:role:add and user:role:remove assign roles. role:perm:add (alias rap) takes the role’s machine name and a comma-separated list of permission names (the exact label strings, as in drush role:list), so a typo gives an error that the permission does not exist. Security notes: a password given on the command line is stored in the shell history and visible in the process list, so pass it from a variable or a secret manager, or create the user without a password and send a drush uli link; permissions granted this way change configuration, which means the next config:import from a branch that does not contain it removes them again (see Users, roles and permissions and Access control and permissions in code).

Cron and queues: core:cron, queue:run

ddev drush cron
ddev drush queue:list
ddev drush meridian:sync --since=0
ddev drush queue:run meridian_collection_sync --time-limit=300 --items-limit=1000 --lease-time=120

drush cron runs the same service as the cron URL and Automated Cron, but in a command-line PHP process with no web server time limit. queue:run NAME processes one queue and stops when the queue is empty or at --time-limit (seconds), --items-limit or when the worker throws SuspendQueueException; --lease-time is how long an item stays claimed before another runner can take it. A worker that has no cron key in its definition is processed only by this command. Scheduling Drush with system cron, systemd timers and Kubernetes CronJobs is in Cron, queues and maintenance, and the worker and the meridian:sync command are in Queue, Batch and Cron APIs. In containers, see Kubernetes Jobs and CronJobs for how to run a command such as drush cron on a schedule.

Recipes: recipe

drush recipe PATH (the alias of recipe:apply) applies a recipe to the installed site, equivalent to php core/scripts/drupal recipe PATH:

ddev composer require drupal/meridian_events_recipe
ddev drush recipe ../recipes/meridian_events
ddev drush recipe ../recipes/meridian_events --input=meridian_events.default_gallery=main-hall
ddev drush cr

The argument is the folder of the recipe, and --input=RECIPE.INPUT=VALUE passes a value to the recipe or to one of its dependencies. Recipes are explained, including inputs and config actions, in Drupal Recipes. A recipe has no update path: it is applied once, its result is configuration, and that configuration is exported afterwards with drush cex.

Code generation: generate

drush generate (alias gen) asks questions and writes the skeleton of a module, plugin, service, form, test or Drush command. Since Drush 12 the generators are part of Drush, not a separate Drupal Code Generator package, and the generator command lists them all.

ddev drush generate                              # pick a generator from the list
ddev drush generate module
ddev drush generate plugin:block --dry-run       # print the code without writing it
ddev drush generate service:event-subscriber
ddev drush generate entity:content
ddev drush generate drush:command                # a Drush command class (see below)
ddev drush generate controller --answer=Meridian --answer=meridian_collection --destination=web/modules/custom

Source: Drush: generate

The generators are grouped: module, theme, controller, hook, field, single-directory-component; plugin:* (block, field formatter/type/widget, queue worker, filter, condition, constraint, REST resource, Views plugins); service:* (event subscriber, access checker, logger, middleware, Twig extension, …​); entity:* (entity:content, entity:configuration, entity:bundle-class); form:*; test:* (unit, kernel, browser, webdriver, nightwatch); yml:* (routing, services, libraries, permissions, links); and drush:* (drush:command, drush:symfony-command, drush:generator, drush:alias-file). The options are --answer (answer a question non-interactively; repeat for each), --destination, --working-dir, --dry-run and --replace. A generator is a starting point, not a template to trust blindly: check that the output uses attributes and dependency injection, that its coding style matches the standards (see Coding standards and static analysis), and that older generator output copied from a book matches today’s conventions. Drupal 10 Development Cookbook (2023) uses drush generate to create custom entities; the command is still right, but the plugins and hooks it produces are the attribute and object-oriented-hook forms in Drupal 11 (see Plugin API and attributes and Hooks and object-oriented hooks).

Evaluating code: php:eval and php:cli

ddev drush php:eval "print \Drupal::config('system.site')->get('name');"
ddev drush php:eval '\Drupal::service(\Drupal\node\NodeAccessRebuild::class)->rebuild();'
ddev drush php:cli
ddev drush php:script scripts/audit-accession-numbers.php

Source: Drush: php:eval

php:eval (aliases eval, ev) runs a PHP snippet with the site bootstrapped and prints the return value with var_dump by default (--format changes it). It is the quickest way to call a service. For anything with complicated shell quoting use php:script FILE, which runs a file instead. Both run with full privileges as user 0, bypass access checks, and may change production data, so they are for investigation and one-off repair; a repeatable change belongs in an update function or deploy hook.

Status: core:status

ddev drush core:status
ddev drush status --field=drupal-version
ddev drush status --fields=drupal-version,php-version,drush-version,db-status,bootstrap,config-sync
ddev drush core:requirements --severity=2

status shows, among other things, the Drupal and PHP versions, the database driver and connection status, the Drush version and configuration files, the install profile, the paths of modules, themes and files, the configuration sync directory and the bootstrap level reached. When a command fails, the value of bootstrap (Successful or empty) tells whether Drush could reach the database at all. core:requirements prints the Status report from the command line, which is suitable for a monitoring probe (see Logging, status report and troubleshooting).

Site aliases

A site alias names a Drupal site (a root directory, a URI and, for a remote site, a way to get there) so that a command can be pointed at it with @name before the command. The alias turns the sequence of connecting to the server over SSH, changing directory and running vendor/bin/drush --uri=... cr into drush @meridian.prod cr.

Where aliases live and what they contain

Aliases are YAML files called NAME.site.yml. The place Drush looks first, and the one to use for a project, is drush/sites/ in the project (next to composer.json). A file called self.site.yml is special: it defines the environments of the current site, and they are addressed as @self.ENV, or just @ENV when no other alias file uses the name. A file called meridian.site.yml defines @meridian.ENV. Other directories, such as ~/.drush/sites or /etc/drush/sites, are searched only when listed in drush.yml; Drush does not search directories recursively, so every folder must be listed.

# drush/sites/meridian.site.yml
local:
  uri: https://meridian.ddev.site
  # No host: the site is on this machine (or in the DDEV container).
  root: /var/www/html/web

staging:
  host: staging.meridian.example
  user: deploy
  root: /var/www/meridian-staging/current/web
  uri: https://staging.meridian.example
  ssh:
    options: '-p 2222 -o StrictHostKeyChecking=yes'
    tty: false
  paths:
    files: sites/default/files
  env-vars:
    MERIDIAN_ENV: staging

prod:
  host: www.meridian.example
  user: deploy
  root: /var/www/meridian/current/web
  uri: https://www.meridian.example
  command:
    sql:
      sync:
        options:
          structure-tables-list: cache,cache_*,sessions,watchdog

The keys:

  • host is the fully qualified domain name of the remote system; leave it out for a local site.

  • user is the account used for SSH (or Docker). Omit it to take it from ~/.ssh/config, which is the better place for it.

  • root is the absolute path of the Drupal root (the web folder). A relative path does not work.

  • uri should be the address people use in a browser, and it selects the site directory in a multisite.

  • os (Linux or Windows) is needed when the remote operating system differs from the local one.

  • ssh.options and ssh.tty are extra ssh options and tty behaviour; paths overrides folders (files, drush-script); env-vars sets environment variables on the remote side.

  • command supplies default options for one command only when that alias is used; the example makes sql:sync from production leave out the contents of cache tables. The same command > group > subcommand > options structure is used in drush.yml.

Usage:

ddev drush site:alias                 # list the aliases Drush found
ddev drush @meridian.staging status
ddev drush @meridian.prod cache:rebuild
ddev drush @meridian.prod watchdog:show --severity-min=Error
ddev drush sql:sync @meridian.staging @self
ddev drush core:rsync @meridian.staging:%files @self:%files
ddev drush site:ssh @meridian.staging

A site specification is an alias given inline: drush deploy@www.meridian.example/var/www/meridian/current/web#www.meridian.example core:status has the form user@host/path#uri. Environment variables can be used anywhere in a Drush configuration file as ${env.NAME}, for instance user: ${env.MERIDIAN_DEPLOY_USER}, which keeps account names out of the repository.

Aliases for containers and clusters

An alias can reach a site that is inside a container or a pod instead of behind SSH. The Docker transport runs Drush with docker compose exec, and the Kubernetes transport with kubectl exec:

# drush/sites/self.site.yml
ci:
  uri: http://web.test
  root: /var/www/html/web
  docker:
    service: web
    exec:
      options: --user www-data

prod-k8s:
  uri: https://www.meridian.example
  root: /var/www/html/web
  kubectl:
    namespace: meridian
    resource: deploy/meridian-web
    container: php
    tty: false
    interactive: false

With these, drush @self.ci status executes docker compose exec web ... and drush @self.prod-k8s cron executes in the named pod without an SSH server in the image. See Compose in practice and kubectl essentials for the container side, and Docker and containers for the Drupal images. For a kubectl alias the deployment pipeline usually has the kubeconfig as a secret and not in the alias file; the kubeconfig and entrypoint keys exist for the cases where it must be named.

Wildcards, @self and pitfalls

A top-level key of '*' is a wildcard environment, and ${env-name} is replaced with the name used on the command line:

# drush/sites/preview.site.yml
'*':
  host: ${env-name}.preview.meridian.example
  user: deploy
  root: /var/www/${env-name}/web
  uri: https://${env-name}.preview.meridian.example

With it drush @preview.pr-412 status addresses a pull-request preview without an alias per branch. The documentation warns that a wildcard matches any name, typos included, so a mistyped @preview.pr-41 tries to connect to a host that does not exist (or to a wrong one); use an alias-alter policy file if that needs reporting.

@self is the current site, which is what sql:sync @prod @self copies into. Common problems: an alias that works for status but not deploy because the remote Drush is not installed or is a different version; root that points to the Git checkout and not to the web folder; a missing uri, giving http://default in links; and SSH asking for a password or host-key confirmation, which hangs in CI (use keys, a known-hosts file and ssh.tty: false).

Configuring Drush: drush.yml

Drush options that are repeated on every command belong in a drush.yml file. The files found are merged, from highest to lowest precedence:

  1. the site folder, such as web/sites/default/drush.yml;

  2. sites/all/drush, WEBROOT/drush or PROJECTROOT/drush;

  3. a path given on the command line with --config;

  4. the user folder, ~/.drush/drush.yml;

  5. the system folder, /etc/drush/drush.yml.

drush status --fields=drush-conf lists the files in use. A file called drush13.yml is loaded only by that major version.

# drush/drush.yml
options:
  # Used by every command. In a multisite, set it in the environment instead (DRUSH_OPTIONS_URI),
  # because Drupal uses the URI to choose the site to bootstrap.
  verbose: false

drush:
  paths:
    alias-path:
      - '${env.HOME}/.drush/sites'
    backup-dir: /var/backups/meridian

command:
  sql:
    dump:
      options:
        structure-tables-key: common
  config:
    import:
      options:
        diff: true

sql:
  # Used by the structure-tables-key option above.
  structure-tables:
    common:
      - cache
      - cache_*
      - sessions
      - watchdog

Any configuration key can be set from the environment by upper-casing it, prefixing DRUSH_ and replacing dots with underscores: export DRUSH_OPTIONS_URI=https://meridian.example. A shared drush.yml committed to the repository sets the team’s defaults, a ~/.drush/drush.yml holds personal ones, and neither should contain passwords or tokens.

Writing a custom command

A module adds Drush commands as classes in its own namespace; no registration file is needed. The Meridian Museum module already has a meridian:sync command (see Queue, Batch and Cron APIs); this section shows the three ways to declare a command and which to choose.

How commands are written, and what changed

Drush has used several styles over the years, and a lot of online material (including the books above) shows the old ones:

Style Declared with Status

Docblock annotations

@command, @option, @usage tags in a method’s docblock, a drush.services.yml file listing the class

drush.services.yml deprecated in Drush 12 and removed in 13; the annotations themselves deprecated as of 13.7

Attributes

#[CLI\Command], #[CLI\Argument], #[CLI\Option], #[CLI\Usage] on a method of a *Commands class

Works in Drush 13 and 14; deprecated as of 13.7 in favour of Console commands

Symfony Console

A class extending Symfony\Component\Console\Command\Command, #[AsCommand], configure() and execute()

Recommended from Drush 13.7 (the commandfile generator produces it from 13.7.1)

The Drush 13.7.0 release notes say that annotated commands are deprecated and that support for them will be removed in a future release, and that core commands move to native Symfony Console commands in 14.x. The page "Writing Drush commands" of the 13.x documentation lists Symfony Console commands as the recommended style and the attribute-annotated and docblock-annotated styles as deprecated. A new command should therefore be a Console command when the project requires Drush 13.7 or later (declare "conflict": {"drush/drush": "<13.7"} in the module’s composer.json), and an attribute command when it must also run on Drush 12 or on 13.0 to 13.6. Both are shown below.

What no longer exists is the file drush.services.yml. Up to Drush 11 a module declared its commands in it; Drush 12 deprecated the file and Drush 13 removed it, so the official pages describe only two ways to inject dependencies in 13: AutowireTrait and a static create() method. A drush.services.yml left in a module is not read by Drush 13 and should be deleted when the module is upgraded. Drupal 10 Development Cookbook (2023) shows the drush.services.yml plus @command annotation form; the drush.services.yml part of it works only up to Drush 12.

Where the class lives

  • In a module: src/Drush/Commands/ (also src/Drush/Generators/ and src/Drush/Listeners/). Drush finds these classes by PSR-4: the namespace is Drupal\MODULE\Drush\Commands and the class name ends in Commands (attribute style) or Command in a file ending in Command.php (Console style). The module must be installed, or the command does not exist.

  • Site-wide, in the project and not in a module: drush/Commands/ExampleCommands.php in the namespace Drush\Commands (no src in the path), committed to the repository or installed by Composer.

  • Global, in the user’s folder, found through an include path in drush.yml: deprecated and to be removed.

Commands of a symlinked Composer package (a path repository) are not found automatically; add the package path to drush.yml while developing. After adding a class, run drush cr, because the command list is cached. If the command is still missing, run drush list and then drush -vvv COMMAND, which prints service-instantiation errors such as a constructor argument that cannot be autowired.

The command meridian:sync-status reports how many collection objects wait in the synchronisation queue:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Drush\Commands;

use Drush\Commands\AutowireTrait;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Drupal\Core\Queue\QueueFactory;

/**
 * Reports the size of the collection synchronisation queue.
 */
#[AsCommand(
  name: self::NAME,
  description: 'Shows how many collection objects wait for synchronisation.',
  aliases: ['msync-status'],
)]
final class SyncStatusCommand extends Command {

  use AutowireTrait;

  public const NAME = 'meridian:sync-status';

  public function __construct(
    #[Autowire(service: 'queue')]
    private readonly QueueFactory $queueFactory,
  ) {
    parent::__construct();
  }

  protected function configure(): void {
    $this->addOption(
      'queue',
      null,
      InputOption::VALUE_REQUIRED,
      'Name of the queue to inspect.',
      'meridian_collection_sync',
    );
    $this->addUsage('--queue=meridian_collection_sync');
  }

  protected function execute(InputInterface $input, OutputInterface $output): int {
    $name = (string) $input->getOption('queue');
    $items = $this->queueFactory->get($name)->numberOfItems();
    $output->writeln(sprintf('%d items in "%s".', $items, $name));
    return Command::SUCCESS;
  }

}

Points to notice:

  • #[AsCommand] is Symfony’s attribute and declares the name, description and aliases; the class is final and has a constant for the name so that other code can refer to it.

  • configure() declares arguments, options and usage text, interact() (not needed here) asks missing values, and execute() does the work and returns an exit code: Command::SUCCESS (0) or Command::FAILURE (1). A non-zero code is what makes a CI step fail.

  • Commands that should offer --format=json|yaml|table use FormatterTrait and by convention put the work in doExecute(); the core twig:unused and sql:dump commands are the models.

  • The constructor takes services. AutowireTrait resolves each argument by its type hint; when the type is not enough (a service whose id is not an alias of its class), the Symfony #[Autowire(service: 'queue')] attribute names the service. Drush 13.7.2 also supports optional services.

  • Drush builds its own container that delegates to Drupal’s. If AutowireTrait is not enough, a static create(Psr\Container\ContainerInterface $container): self method receives that container; type-hint the PSR interface, not Symfony’s.

An attribute command

The older style puts everything on a method. It is the style of the meridian:sync command on the queue page, and it is the one that runs on every Drush from 12 to 14. This command lists the objects on display in a gallery, in any output format:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Drush\Commands;

use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drush\Attributes as CLI;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\AutowireTrait;
use Drush\Commands\DrushCommands;

/**
 * Drush commands that read the Meridian collection.
 */
final class MeridianReportCommands extends DrushCommands {

  use AutowireTrait;

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {
    parent::__construct();
  }

  /**
   * Lists the collection objects that are on display.
   */
  #[CLI\Command(name: 'meridian:on-display', aliases: ['mod'])]
  #[CLI\Argument(name: 'gallery', description: 'Machine name of a gallery term; omit for all galleries.')]
  #[CLI\Option(name: 'limit', description: 'Maximum number of rows.')]
  #[CLI\Usage(name: 'drush meridian:on-display main-hall --limit=20', description: 'Twenty objects in the main hall.')]
  #[CLI\Usage(name: 'drush mod --format=json', description: 'Every object on display, as JSON.')]
  #[CLI\FieldLabels(labels: ['id' => 'ID', 'accession' => 'Accession number', 'title' => 'Title'])]
  #[CLI\DefaultFields(fields: ['id', 'accession', 'title'])]
  #[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
  public function onDisplay(?string $gallery = NULL, array $options = ['limit' => 50, 'format' => 'table']): RowsOfFields {
    $storage = $this->entityTypeManager->getStorage('collection_object');
    $query = $storage->getQuery()
      ->accessCheck(FALSE)
      ->condition('on_display', TRUE)
      ->range(0, (int) $options['limit']);
    if ($gallery !== NULL) {
      $query->condition('gallery.entity.name', $gallery);
    }

    $rows = [];
    foreach ($storage->loadMultiple($query->execute()) as $object) {
      $rows[] = [
        'id' => $object->id(),
        'accession' => $object->get('accession_number')->value,
        'title' => $object->label(),
      ];
    }
    return new RowsOfFields($rows);
  }

}

Reading this class:

  • an attribute command class extends DrushCommands, which also provides logger() and io(), and a constructor that takes services calls parent::__construct();

  • the method’s parameters are the arguments ($gallery) and the $options array holds the options with their defaults, and the return value (here RowsOfFields) is formatted by Drush according to --format (table, json, yaml, csv, list, …​); the command returns data and never calls print;

  • #[CLI\Argument], #[CLI\Option] and #[CLI\Usage] produce the drush help meridian:on-display text; #[CLI\FieldLabels] and #[CLI\DefaultFields] set the column headings and the default columns;

  • #[CLI\Bootstrap] states how much of Drupal the command needs. FULL is the default for a Drupal command, and NONE or ROOT lets a command run without a database, for instance a generator;

  • the entity query calls accessCheck(FALSE) explicitly (see Database API and entity queries): that is deliberate in a command an operator runs on the server, and wrong in code that serves user input;

  • other attributes validate before the command runs (that a module is enabled, that a permission exists), add the shared option sets (for example the table selection options), or mark a method as a hook into another command, such as a post-command hook for cache:rebuild. The complete set is in the src/Attributes directory of the Drush repository.

Testing and running a command

ddev drush cr
ddev drush list --filter=meridian
ddev drush help meridian:on-display
ddev drush meridian:on-display main-hall --limit=5
ddev drush mod --format=json
ddev drush meridian:sync-status --queue=meridian_collection_sync

A Drush command is tested with a Kernel test of its service (the logic belongs in a service such as the CollectionSyncPlanner of the queue page, so it can be tested without Drush) and, where the command itself needs a test, with Drush’s own DrushTestTrait from the drush/drush package, which executes the command and returns its output (see Automated testing). Keep a command thin: parse the input, call a service, format the output.

Commands from a recipe or a pipeline

Commands are also the building block of automation. The Meridian Museum deployment script is the sequence

composer install --no-dev --optimize-autoloader
vendor/bin/drush deploy -y
vendor/bin/drush meridian:sync-status

and its details, including the CI/CD pipeline, are in Deployment and CI/CD.

Common errors

  • "Command not found": the module that defines it is not installed, the class name or namespace does not follow the PSR-4 rule (Drupal\meridian_collection\Drush\Commands), the file is in src/Commands (the Drush 9 to 11 location, no longer read), or the container was not rebuilt with drush cr.

  • "Drush needs a Drupal site" or "Could not find a Drupal settings.php": run it from inside the project, or pass --root and --uri, or use an alias.

  • "The command could not be executed because the Drupal site is not installed / database": drush status shows the bootstrap level; fix settings.php or the database host (in DDEV use ddev drush, because localhost inside the container is not the database).

  • A command fails only in CI: check -y (a prompt waits for an answer), the memory limit of the CLI PHP, and whether vendor/ was installed with --no-dev (a development-only package that a command needs).

  • Links print http://default/...: no uri; pass --uri, use an alias, or set DRUSH_OPTIONS_URI.

  • A remote alias fails with "command not found": Drush is not installed in the remote project, or the remote PATH does not include the project’s vendor/bin; set paths.drush-script in the alias.

  • config:import removes things: the sync directory is incomplete; use config:status and --diff first.

  • A deploy hook did not run: it is in the wrong file name (MODULE.deploy.php), the function name does not start with the module name, or drush deploy:hook-status shows it already marked as complete.

Security

  • Drush runs with the privileges of the operating system user and as user 1 inside Drupal, with no permission checks. Limit who has a shell on the server, and who can run a pipeline step that has Drush access.

  • Drush is for the command line only; it must not be reachable from the web. The vendor/ folder is outside the web root in the recommended project layout, and a web server configuration that serves vendor/bin is a vulnerability.

  • Secrets (database credentials, hash_salt, API tokens, deployment SSH keys) come from the environment or a secret store and never from an alias file, a drush.yml or a command line (see settings.php and environments and Security hardening).

  • Treat sql:dump files, uli links and config:export output as sensitive. Sanitise before a database leaves production.

  • Prefer drush deploy in a pipeline, protected by the pipeline’s own approvals, to an interactive SSH session on production.

References