Drupalwoo ☉

Building a custom Drupal Drush command for exporting site data

Whenever a Drupal site accumulates content, configuration, or user records that need to leave the database, administrators reach for repeatable tooling instead of ad-hoc SQL one-liners. A purpose-built Drush command fits neatly into deploy scripts, content audits, and reporting routines run by agencies from Sydney to Perth. It also gives technical editors a documented entry point that they can hand to non-developer teammates who are comfortable in the terminal.

Below you will find a walkthrough that covers scaffolding, coding, performance, and distribution. The approach works whether you maintain a single community publication or operate a network of sites for a Brisbane-based retailer with thousands of SKUs. By the end, you will have a reusable command that exports data cleanly, handles large tables without exhausting PHP memory, and runs from any shell.

Why Drush commands suit Australian content operations

Drupal powers a meaningful share of government, education, and media sites across Australia, and the teams running them often need scheduled dumps for compliance, media partners, or editorial review. A custom Drush command removes the friction of php-cli hacks and phpMyAdmin exports that break on multi-gigabyte tables. It also plays nicely with cron-like orchestration run from AEST business hours, where exports triggered at 02:00 in Sydney may need to land on a partner's SFTP by the time their office opens.

Beyond automation, commands provide discoverability. The drush list output acts as living documentation: anyone joining the project can see available operations, read the help text, and call the right flags. For remote teams stretched across Melbourne, Adelaide, and Cairns, that discoverability reduces the chance of someone crafting an export script that quietly conflicts with the one written six months earlier. It also makes onboarding easier when a contractor flies in for a two-week engagement and needs to be productive on day one.

Scaffolding the custom command module

Start by creating a lightweight module in web/modules/custom rather than dropping code into an existing feature module. Keeping export tooling isolated makes upgrades safer, and the module can later be reused on another site without dragging unrelated dependencies along. The directory structure should look like this: export_tool/export_tool.info.yml, export_tool/export_tool.services.yml, and export_tool/src/Drush/Commands/ExportCommands.php. Adding a README.md at the module root makes the package easier to share with other internal teams.

The info file needs the standard Drupal 9 or 10 metadata plus a drush key that tells Drush where to look for command classes. A minimal version might declare name, type, core_version_requirement, and a drush.services entry pointing to the services yml. The services yml then registers the command class with the dependency injection container so it can receive services such as the database connection, the entity type manager, or a custom exporter. Pinning core compatibility to ^9 || ^10 ensures the same module can travel between legacy and current sites without surprise.

Writing the command class and its dependencies

Drush 10 and 11 use the symfony-style command pattern wrapped in a class extending DrushCommands. Each public method becomes a sub-command when tagged with @command, and @help, @option, and @usage annotations document its interface. A typical signature is something like public function exportNodes(array $options = []), where $options carries flags such as --content-type, --format, or --limit. The annotations appear in the rendered help output, which reduces the need for a separate wiki page.

Inject services through the constructor rather than calling \Drupal::service() inside the method. Injecting Database, EntityTypeManagerInterface, and a private NodeExporter service keeps the command testable and prevents accidental coupling to globals. Hook implementations like hook_preprocess_node() never need to run during a CLI export, so the command should bypass them by using lower-level database queries when speed matters more than rendered output. Where logic needs to be reused outside Drush, push it into a dedicated service so the command stays thin.

Supporting multiple export formats

Most editorial teams in Australia mix spreadsheets with API feeds, so supporting CSV alongside JSON covers the common cases without overengineering the first release. CSV remains the lingua franca for finance teams using Excel and Google Sheets, while JSON suits integration with headless front-ends and JavaScript dashboards. XML is occasionally useful for legacy partners, but it adds schema complexity that rarely earns its keep outside regulated reporting. Choosing the right default format up front avoids retrofitting later.

The exporter service should accept a format string and return a streamable string rather than building a massive array in memory. Writing directly to a StreamWrapper or a temp file path lets operators pipe the command into gzip, aws s3 cp, or an rsync job without holding the full payload in RAM. For sites operating under the Notifiable Data Breaches scheme, deterministic file naming using UTC timestamps avoids confusion when an export is logged in the audit register. A trailing checksum line, or a sidecar .sha256 file, also helps reviewers verify integrity after transit.

Choosing between direct, batched, and queued execution

Approach Memory footprint Best fit Drupal API
Direct loop High for large tables Small datasets under a few thousand rows Plain foreach over query result
Batch API Predictable, capped per pass Mid-size content exports during cron BatchBuilder, batch_set()
Queue worker Low, distributed Continuous exports over many minutes QueueInterface, cron-triggered

The rows above summarise the three execution styles that Drush commands typically adopt. Direct loops are fastest to write but risk PHP memory exhaustion on sites with tens of thousands of nodes or users. The Batch API breaks the work into chunks, displays progress when run interactively, and stays friendly to shared hosting plans popular with Australian SMBs. Queue workers shine when exports must run in the background while the CLI returns control to the operator.

For most editorial exports, the Batch API strikes a useful balance. Wrap the iteration in a BatchBuilder, set a reasonable setFinish callback that writes a summary log, and call drush_batch_process() to execute the batch from inside the command. Operators then see progress bars in the terminal without the command blocking indefinitely on shared infrastructure. If a batch step throws, catch it at the operation boundary, log to watchdog, and let the next tick retry rather than crashing the whole run.

Testing locally and across Pantheon environments

Before touching production data, run the command on a copy of the database exported to your local machine. Vagrant, DDEV, or Lando all reproduce the PHP version and extensions that Pantheon runs in its containers, which makes them reliable proxies for staging. Once the local result looks correct, push the export branch to a multidev environment and verify there too. The setting up a staging environment for Drupal using Pantheon overview guide walks through that workflow if you have not done it before.

When verifying outputs, diff a previous export against the new one with diff -u or csvkit's csvstat to confirm row counts and spot-check representative records. Make sure the command respects --uri so multi-site installations report against the correct domain, and test with --root=/var/www/html/somepath if you run symlinked codebases. Australian privacy considerations under the Privacy Act mean exports containing personal information should never leave the staging environment unencrypted; pipe them through gpg --symmetric or upload directly to a private bucket rather than leaving intermediate files on the application server. Logging the SHA checksum to a local ledger keeps an audit trail attached to each run.

Packaging and sharing the command

Once the command works on your primary site, package the module so other projects can pull it in via Composer. Add a composer.json beside export_tool.info.yml declaring a type of drupal-module, a name like yourorg/drupal-export-tool, and a require section pinning drush/drush to a version range matching your production stack. Pushing the result to a private Packagist mirror, an internal GitLab, or the Drupal.org contrib space turns the command into a shareable asset. Tagging releases semantically lets consumers upgrade without breaking their existing flags.

Australia's Drupal community regularly showcases tooling at DrupalCamp Melbourne and Sydney, and a polished exporter module is exactly the kind of utility that earns useful feedback. Document accepted flags in the README, include a sample drush.export-tool.yml configuration file, and add a Drush command hooks implementation so other modules can register additional output formats. With those pieces in place, the command becomes part of a maintainable CLI toolbox rather than a forgotten script in a private repo.