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/drushdecides which line a project gets, so acomposer require drush/drushon a Drupal 11.4 site picks the newest compatible release, andcomposer.lockfreezes 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 runsvendor/bin/drushthere); -
a project that needs a development-only tool can still put Drush in
require-dev, but a deployment that runsdrush deployon the server then needscomposer installwithout--no-dev, so most projects keep Drush inrequire.
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
Source: Drush: core:status
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.
The global options and the help system
Every command accepts the same global options. The ones used constantly:
| Option | Meaning |
|---|---|
|
Accepts the default answer to every prompt, the same as |
|
The base URL of the site. It builds absolute links (the one-time login link of |
|
Message verbosity, from normal to debug. |
|
Prints the commands that would be run, for the commands that support it; |
|
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 |
|---|---|---|
|
|
Rebuilds the Drupal container, the registries (routes, plugins, hooks) and all caches. The fix for "I added a file and nothing happens". |
|
|
Writes the active configuration to the sync directory (see Configuration management). |
|
|
Makes the active configuration equal to the sync directory. |
|
|
Runs pending |
|
none listed |
Runs the whole post-deployment sequence in one command (drush deploy). |
|
|
Prints a one-time login link, optionally for a given user and path. |
|
|
Installs one or more modules (and the modules they depend on). |
|
|
Uninstalls modules. |
|
|
Exports the database with |
|
|
Opens the database client with the site’s credentials. |
|
|
Replaces personal data in the database (Sanitising a database). |
|
|
Prints the most recent log entries, filtered by type or severity. |
|
|
Writes a value to the State system. |
|
|
Reads a configuration object or one of its keys. |
|
|
Creates a user account. |
|
|
Grants permissions to a role. |
|
|
Runs cron: every |
|
|
Processes one queue by name. |
|
|
Applies a recipe (see Drupal Recipes). |
|
|
Generates module, plugin, service, test and Drush command skeletons. |
|
|
Shows the versions, paths and database connection Drush found. |
|
|
Writes one key (or a whole object) of the active configuration. |
|
|
Copies the database from one site alias to another. |
|
|
Evaluates a PHP snippet in a bootstrapped site. |
|
|
Turns maintenance mode on ( |
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
Source: Drush: cache:rebuild
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:exportalso accepts--add(runsgit add -pso that you pick the hunks),--commitwith--message(commits every exported file) and--destination(writes to another directory, which is a backup directory when no value is given). -
config:import --partialimports 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:getreads the stored value. Without--include-overriddenit does not show values forced fromsettings.php($config['system.site']['name'] = ...), so a value that "does not change" is often an override. -
config:settakes--input-format=yamlto 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 ? -
Source: Drush: config:set
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:
-
drush updatedb -
drush config:import -
drush cache:rebuild -
drush deploy:hook, which runs the pending deploy hooks -
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.
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']);
}
Source: Drush: Deploy command for Drupal
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
Source: Drush: user:login
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
Source: Drush: pm:install and Drush: pm:uninstall
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
Source: Drush: sql:dump, Drush: sql:cli and Drush: sql:sync
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
Source: Drush: sql:sanitize
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
Source: Drush: watchdog:show
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
Source: Drush: state:set
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
Source: Drush: user:create and Drush: role:perm:add
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
Source: Drush: core:cron and Drush: queue:run
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
Source: Drush: recipe:apply
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
Source: Drush: Site aliases
The keys:
-
hostis the fully qualified domain name of the remote system; leave it out for a local site. -
useris the account used for SSH (or Docker). Omit it to take it from~/.ssh/config, which is the better place for it. -
rootis the absolute path of the Drupal root (thewebfolder). A relative path does not work. -
urishould be the address people use in a browser, and it selects the site directory in a multisite. -
os(LinuxorWindows) is needed when the remote operating system differs from the local one. -
ssh.optionsandssh.ttyare extrasshoptions and tty behaviour;pathsoverrides folders (files,drush-script);env-varssets environment variables on the remote side. -
commandsupplies default options for one command only when that alias is used; the example makessql:syncfrom production leave out the contents of cache tables. The samecommand > group > subcommand > optionsstructure is used indrush.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
Source: Drush: Site aliases
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:
-
the site folder, such as
web/sites/default/drush.yml; -
sites/all/drush,WEBROOT/drushorPROJECTROOT/drush; -
a path given on the command line with
--config; -
the user folder,
~/.drush/drush.yml; -
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
Source: Drush: Using Drush configuration
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 |
|
|
Attributes |
|
Works in Drush 13 and 14; deprecated as of 13.7 in favour of Console commands |
Symfony Console |
A class extending |
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/(alsosrc/Drush/Generators/andsrc/Drush/Listeners/). Drush finds these classes by PSR-4: the namespace isDrupal\MODULE\Drush\Commandsand the class name ends inCommands(attribute style) orCommandin a file ending inCommand.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.phpin the namespaceDrush\Commands(nosrcin the path), committed to the repository or installed by Composer. -
Global, in the user’s folder, found through an
includepath indrush.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.
A Console command (recommended)
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;
}
}
Source: Drush: Writing commands and Drush: Dependency injection
Points to notice:
-
#[AsCommand]is Symfony’s attribute and declares the name, description and aliases; the class isfinaland 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, andexecute()does the work and returns an exit code:Command::SUCCESS(0) orCommand::FAILURE(1). A non-zero code is what makes a CI step fail. -
Commands that should offer
--format=json|yaml|tableuseFormatterTraitand by convention put the work indoExecute(); the coretwig:unusedandsql:dumpcommands are the models. -
The constructor takes services.
AutowireTraitresolves 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
AutowireTraitis not enough, a staticcreate(Psr\Container\ContainerInterface $container): selfmethod 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);
}
}
Source: Drush: Writing commands
Reading this class:
-
an attribute command class extends
DrushCommands, which also provideslogger()andio(), and a constructor that takes services callsparent::__construct(); -
the method’s parameters are the arguments (
$gallery) and the$optionsarray holds the options with their defaults, and the return value (hereRowsOfFields) is formatted by Drush according to--format(table,json,yaml,csv,list, …); the command returns data and never callsprint; -
#[CLI\Argument],#[CLI\Option]and#[CLI\Usage]produce thedrush help meridian:on-displaytext;#[CLI\FieldLabels]and#[CLI\DefaultFields]set the column headings and the default columns; -
#[CLI\Bootstrap]states how much of Drupal the command needs.FULLis the default for a Drupal command, andNONEorROOTlets 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 thesrc/Attributesdirectory 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
Source: Drush: Writing commands
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 insrc/Commands(the Drush 9 to 11 location, no longer read), or the container was not rebuilt withdrush cr. -
"Drush needs a Drupal site" or "Could not find a Drupal settings.php": run it from inside the project, or pass
--rootand--uri, or use an alias. -
"The command could not be executed because the Drupal site is not installed / database":
drush statusshows the bootstrap level; fixsettings.phpor the database host (in DDEV useddev drush, becauselocalhostinside 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 whethervendor/was installed with--no-dev(a development-only package that a command needs). -
Links print
http://default/...: nouri; pass--uri, use an alias, or setDRUSH_OPTIONS_URI. -
A remote alias fails with "command not found": Drush is not installed in the remote project, or the remote
PATHdoes not include the project’svendor/bin; setpaths.drush-scriptin the alias. -
config:importremoves things: the sync directory is incomplete; useconfig:statusand--difffirst. -
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, ordrush deploy:hook-statusshows 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 servesvendor/binis 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, adrush.ymlor a command line (see settings.php and environments and Security hardening). -
Treat
sql:dumpfiles,ulilinks andconfig:exportoutput as sensitive. Sanitise before a database leaves production. -
Prefer
drush deployin a pipeline, protected by the pipeline’s own approvals, to an interactive SSH session on production.
References
-
Drush: Install (compatibility table and Composer installation)
-
Drush: Commands (the command reference, with aliases and options)
-
Drush releases on GitHub (13.7.0 and later release notes)