The power of CLI in Drupal development
Imagine you’re managing a news website with thousands of articles. One morning, your editor asks, “How many ‘breaking news’ articles do we have?”
Without a custom Drush command, you’d log into the admin UI, filter the content list, and count manually tedious process.
But with a custom command, you simply type:
drush node-count-- type=breaking_news
…and get an answer in seconds!
This is the magic of Drush: transforming time-consuming tasks into instant insights. In this blog, we’ll walk through creating and refining custom Drush commands, showing how they can become indispensable tools in your Drupal workflow.
Why custom drush commands matter
Drush isn’t just a shortcut’s a command-line interface that lets you interact with Drupal at its core. Custom commands extend this power, letting you automate workflows, query data, and manage your site without touching a browser.
Real-world impact
Here’s how Drush can change your workflow:
- Automate repetitive tasks: Like clearing caches or rebuilding indexes.
- Quickly retrieve site information: Count nodes, check configuration, or validate data integrity.
- Streamline development: Run tests, generate reports, or pre-process assets.
- Extend Drupal’s capabilities: Create tools tailored to your site’s unique needs.
Building blocks of a custom drush command
Let’s start with a concrete example: a command that counts nodes. This will illustrate the key components and decisions involved in building custom commands.
Project structure
node_count_module/
├── node_count_module.info.yml
├── node_count_module.services.yml
└── src/
└── Commands/
└── NodeCountCommands.php
This structure follows Drupal’s conventions, ensuring your command is discoverable and maintainable.
Module information file
# node_count_module.info.yml
name: Node Count Module
type: module
description: Provides a Drush command to count nodes
core_version_requirement: ^9 || ^10 || ^11
package: Custom
This metadata tells Drupal what your module does and which versions it supports.
Services configuration
# node_count_module.services.yml
services:
node_count_module.drush_commands:
class: Drupal\node_count_module\Commands\NodeCountCommands
arguments:
- '@entity_type.manager'
tags:
- { name: drush.command }
Here, you’re defining a service that Drush can recognise. The @entity_type.manager argument gives your command access to Drupal’s entity system.
Learn more about services in Drupal
The command implementation
Let’s look at the code for a custom node-count command, with full validation and helpful output.
<?php
namespace Drupal\node_count_module\Commands;
use Drush\Commands\DrushCommands;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Provides Drush commands for node counting.
*/
class NodeCountCommands extends DrushCommands {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new NodeCountCommands object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager service.
*/
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->entityTypeManager = $entityTypeManager;
}
/**
* Count the number of nodes in the Drupal site.
*
* @command custom:node-count
* @aliases node-count nc
* @option type Filter nodes by type
* @usage custom:node-count
* Display total number of nodes
* @usage custom:node-count --type=article
* Count nodes of type 'article'
*
* @param array $options
* An array of options.
*
* @return int
* The number of nodes.
*/
public function nodeCount($options = ['type' => null]) {
// Validate node type if provided.
if (!empty($options['type']) && !$this->entityTypeManager->getStorage('node_type')->load($options['type'])) {
throw new \InvalidArgumentException("Node type '{$options['type']}' does not exist.");
}
// Get the node storage.
$nodeStorage = $this->entityTypeManager->getStorage('node');
$query = $nodeStorage->getQuery()->accessCheck(true);
// Filter by node type if specified.
if (!empty($options['type'])) {
$query->condition('type', $options['type']);
}
// Execute the query and get the count.
$count = $query->count()->execute();
// Output the result.
if (!empty($options['type'])) {
$this->output()->writeln("Total nodes of type '{$options['type']}': $count");
} else {
$this->output()->writeln("Total nodes in the site: $count");
}
return $count;
}
}
How it works
This command uses Drupal’s entity query system to count nodes, varying the result based on the --type option. The entity_type.manager service provides access to this system, making the command reusable and testable.
Official Drush Command Authoring Docs
Integrating into existing modules
You don’t always need a new module. Adding commands to existing ones keeps your codebase clean.
Steps to integrate
- Create the command file: Place NodeCountCommands.php in src/Commands/.
- Update services YAML: Register the command in your module’s services.yml:
services:
your_existing_module.drush_commands:
class: Drupal\your_existing_module\Commands\NodeCountCommands
arguments:
- '@entity_type.manager'
tags:
- { name: drush.command }
This approach avoids bloating your project with unnecessary modules while keeping code organised.
Drupal 10 and 11: modernising your command
As Drupal evolves, so should your commands. Here’s how you can update the same command for Drupal 10/11 using modern PHP features.
<?php
namespace Drupal\your_module\Commands;
use Drush\Commands\DrushCommands;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides Drush commands for node counting.
*/
class NodeCountCommands extends DrushCommands {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager
) {}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('entity_type.manager')
);
}
/**
* Count the number of nodes in the Drupal site.
*
* @command custom:node-count
* @aliases node-count nc
* @option type Filter nodes by type
* @usage custom:node-count
* Display total number of nodes
* @usage custom:node-count --type=article
* Count nodes of type 'article'
*/
public function nodeCount($options = ['type' => null]) {
if (!empty($options['type']) && !$this->entityTypeManager->getStorage('node_type')->load($options['type'])) {
throw new \InvalidArgumentException("Node type '{$options['type']}' does not exist.");
}
$nodeStorage = $this->entityTypeManager->getStorage('node');
$query = $nodeStorage->getQuery()->accessCheck(true);
if (!empty($options['type'])) {
$query->condition('type', $options['type']);
}
$count = $query->count()->execute();
if (!empty($options['type'])) {
$this->output()->writeln("Total nodes of type '{$options['type']}': $count");
} else {
$this->output()->writeln("Total nodes in the site: $count");
}
return $count;
}
}
Key improvements
- Readonly properties: Prevent accidental modifications.
- Container integration: Uses Symfony’s container for better dependency management.
- Static factory method: Enables cleaner instantiation, especially in testing environments.
These changes reflect Drupal’s shift toward stricter type safety and modern PHP practices.
Real-world example: content audit command
Scenario
A content agency migrates 50,000 articles from a legacy CMS. They need to verify data integrity:
- Are all articles imported?
- Do image fields reference valid media?
- Is taxonomy metadata preserved?
Solution: a custom drush command
/**
* Provides Drush commands for content auditing.
*/
class ContentAuditCommands extends DrushCommands {
protected $entityTypeManager;
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->entityTypeManager = $entityTypeManager;
}
/**
* Audit taxonomy terms in a vocabulary.
*
* @command custom:audit-taxonomy
* @option vocabulary The vocabulary machine name
* @usage custom:audit-taxonomy --vocabulary=tags
* Audit all terms in the 'tags' vocabulary
*/
public function auditTaxonomy($options = ['vocabulary' => 'tags']) {
$termStorage = $this->entityTypeManager->getStorage('taxonomy_term');
$query = $termStorage->getQuery()->condition('vid', $options['vocabulary']);
$termCount = $query->count()->execute();
$this->output()->writeln("Total terms in '{$options['vocabulary']}': $termCount");
return $termCount;
}
}
Why it works
This command leverages Drupal’s entity system to audit taxonomy terms, ensuring the migration team gets immediate feedback on taxonomy field integrity.
Best practices for custom commands
1. Use dependency injection
Injecting services like entity_type.manager makes your command testable and flexible.
Learn more about Dependency Injection in Drupal
2. Provide clear descriptions
Use the @usage annotation to document how users should run the command.
3. Handle edge cases
What happens if a node type doesn’t exist? Add validation:
if (!empty($options['type']) && !$this->entityTypeManager->getStorage('node_type')->load($options['type'])) {
throw new \InvalidArgumentException("Node type '{$options['type']}' does not exist.");
}
This ensures your command fails gracefully instead of returning cryptic errors.
Common pitfalls and how to avoid them
Pitfall 1: Missing service tags
Forgetting to add drush.command in services.yml means Drush won’t recognise your command.
Pitfall 2: Overlooking access checks
Using accessCheck(true) ensures the command respects user permissions. Skipping it might expose sensitive data.
Pitfall 3: Hardcoding dependencies
Never hardcode services like \Drupal::entityTypeManager(). Always inject them for better testability.
Troubleshooting: When things go wrong
Case study: missing command after enabling the module
Symptoms: drush list doesn’t show your command.
Diagnosis:
- Confirm the module is enabled (drush pm:list).
- Check the service configuration for drush.command tags.
- Run drush cache-rebuild to refresh service definitions.
Case study: slow query on large sites
Symptoms: The command takes 10+ seconds to count nodes.
Fix: Add caching:
use Drupal\Core\Cache\Cache;
public function nodeCount($options = ['type' => null]) {
$cache_key = 'node_count:' . ($options['type'] ?? 'all');
$cached = \Drupal::cache()->get($cache_key);
if ($cached) {
$this->output()->writeln("Cached count: {$cached->data}");
return $cached->data;
}
// Validate node type if provided.
if (!empty($options['type']) && !$this->entityTypeManager->getStorage('node_type')->load($options['type'])) {
throw new \InvalidArgumentException("Node type '{$options['type']}' does not exist.");
}
$nodeStorage = $this->entityTypeManager->getStorage('node');
$query = $nodeStorage->getQuery()->accessCheck(true);
if (!empty($options['type'])) {
$query->condition('type', $options['type']);
}
$count = $query->count()->execute();
\Drupal::cache()->set($cache_key, $count, Cache::PERMANENT, ['node']);
if (!empty($options['type'])) {
$this->output()->writeln("Total nodes of type '{$options['type']}': $count");
} else {
$this->output()->writeln("Total nodes in the site: $count");
}
return $count;
}
This adds caching to prevent redundant queries during frequent audits.
Expanding the possibilities: beyond node counting
Custom commands aren’t limited to counting nodes. Let’s explore advanced use cases:
Use case 1: bulk entity updates
A real estate platform uses a command to update property listings when market data changes:
public function updatePropertyPrices($options = ['increase' => 5]) {
$query = $this->entityTypeManager->getStorage('node')->getQuery()
->condition('type', 'property');
$nids = $query->execute();
foreach ($this->entityTypeManager->getStorage('node')->loadMultiple($nids) as $node) {
$price = $node->get('field_price')->value * (1 + ($options['increase'] / 100));
$node->set('field_price', $price)->save();
}
$this->output()->writeln("Increased prices by {$options['increase']}% for " . count($nids) . " properties.");
}
Use case 2: health checks
A healthcare site runs nightly health checks to ensure critical content is up-to-date:
public function checkContentHealth() {
$query = $this->entityTypeManager->getStorage('node')->getQuery()
->condition('status', 1)
->condition('changed', strtotime('-1 year'), '<');
$staleNodes = $query->execute();
if (empty($staleNodes)) {
$this->output()->writeln("All content is up-to-date!");
} else {
$this->output()->writeln("Found " . count($staleNodes) . " stale nodes. Run `drush node-check stale` for details.");
}
}
Best practices in action
1. Meaningful aliases
Instead of custom:node-count, use nc for brevity.
2. Usage examples
Always include @usage annotations. For instance:
/**
* @usage custom:node-count --type=article
* Count all articles
*/
3. Help text
Describe what the command does in plain language:
Describe what the command does in plain language:
/**
* Count nodes with optional filtering by type.
*
* This command helps you quickly audit content volume across content types.
*/
Conclusion
Custom Drush commands, when thoughtfully built, do more than automate tasks—they shape a cleaner, faster, and more predictable development workflow.
By applying best practices like dependency injection, validation, and clear documentation, developers create commands that are easy to maintain and trusted across teams.
Over time, these tools become integral to managing complexity, improving deployment confidence, and making Drupal development feel less like firefighting and more like engineering.
Ready to go further? Explore the official Drush command authoring docs and Drupal’s services and dependency injection guide to keep levelling up your CLI skills!
Happy Drushing!