Drupalwoo ☉

Building a Custom Drupal Drush Command for Configuration Synchronisation

Drupal configuration can be exported, reviewed in Git, and imported between environments with Drush. That workflow is reliable for routine deployments, but many teams eventually need a command that adds project-specific checks, clearer output, or a controlled subset of configuration.

A custom Drush command can turn a manual deployment sequence into a repeatable operation. This is useful for Australian agencies managing several client sites, internal teams deploying from Sydney or Melbourne, and organisations coordinating releases across Australian Eastern Time and Perth-based operations.

Choosing The Right Synchronisation Strategy

Before writing PHP, decide what “synchronise” means for the project. A full configuration import makes the active site match the configuration stored in the configured sync directory. A targeted synchronisation imports selected configuration objects, while a validation command reports differences without changing the site.

Approach Suitable use Main consideration
drush config:import Standard release deployment Imports the complete configuration set
Custom wrapper command Validation, logging, permissions, or deployment rules Still relies on Drupal’s configuration API
Targeted import A controlled group of configuration objects Dependencies can make selection complicated
Direct Config::save() calls Small runtime changes Not suitable for normal deployment synchronisation
Configuration Split Environment-specific settings Requires clear split and environment rules

For most Drupal 10 and Drupal 11 projects, the safest custom command is a wrapper around the core configuration import process. It can check that the expected directory exists, refuse to run in a protected environment, display a preview, and record useful deployment information.

Do not copy YAML files directly into the active configuration directory. Drupal configuration includes dependencies, validation, entity changes, and import events. The configuration importer handles those concerns in the correct order.

Preparing A Drupal Module

Create a small custom module for deployment utilities. A module named site_tools would normally contain site_tools.info.yml, site_tools.services.yml, and a src/Commands directory:

web/modules/custom/site_tools/
├── site_tools.info.yml
├── site_tools.services.yml
└── src/
    └── Commands/
        └── SiteToolsCommands.php

The module information file can be minimal:

name: Site Tools
type: module
description: Project-specific administration and deployment commands.
core_version_requirement: ^10 || ^11
package: Custom

With Drush 12 or later, command discovery is commonly handled by a service definition. Add the command class as a tagged service:

services:
  site_tools.commands:
    class: Drupal\site_tools\Commands\SiteToolsCommands
    arguments:
      - '@config.storage.sync'
      - '@config.storage'
      - '@config.importer'
      - '@logger.factory'
    tags:
      - { name: drush.command }

The exact services needed depend on the implementation. Injecting services is preferable to calling \Drupal::service() throughout the command because dependencies remain visible, easier to test, and easier to replace. Enable the module before testing:

drush en site_tools -y
drush cache:rebuild
drush list site-tools

On a managed Australian hosting platform, the deployment user may have permission to run Drush but not to enable modules. Enable the utility module during a normal code deployment, then use the command during releases.

Creating The Command Class

A basic command class extends DrushCommands. The following example uses Drush command annotations, which remain familiar across many Drupal projects:

<?php

namespace Drupal\site_tools\Commands;

use Drupal\Core\Config\StorageInterface;
use Drush\Commands\DrushCommands;

final class SiteToolsCommands extends DrushCommands {

  public function __construct(
    private readonly StorageInterface $syncStorage,
    private readonly StorageInterface $activeStorage,
  ) {
    parent::__construct();
  }

  /**
   * Synchronises Drupal configuration from the configured sync directory.
   *
   * @command site-tools:sync-config
   * @aliases sts
   * @option preview Show the changes without importing them.
   * @option yes Skip the confirmation prompt.
   * @usage drush site-tools:sync-config
   * @usage drush site-tools:sync-config --preview
   */
  public function syncConfig(array $options = [
    'preview' => FALSE,
    'yes' => FALSE,
  ]): void {
    $this->output()->writeln('Checking configuration synchronisation...');
  }

}

The command name uses a namespace-like format: site-tools:sync-config. This keeps project commands distinct from core commands and makes shell history easier to understand. An alias such as sts is convenient during local development, although the full name is clearer in deployment scripts.

The --preview and --yes options support two different operating modes. A developer can inspect the proposed change set, while a non-interactive CI job can run with --yes. Avoid making production imports automatically bypass confirmation unless the deployment pipeline has already performed validation.

Drush attributes are another option in current versions. They provide PHP-native command metadata, but annotations are still common in established Drupal codebases. Select the style that matches the Drush version and coding standards used by the project.

Comparing Configuration Before Import

The core StorageComparer compares a source storage with a target storage. For an import, the source is the synchronisation storage and the target is the active storage:

use Drupal\Core\Config\StorageComparer;

$comparer = new StorageComparer(
  $this->syncStorage,
  $this->activeStorage
);
$comparer->createChangelist();

$change_list = $comparer->getChangelist();

The change list is grouped by operation. Typical groups include create, update, and delete, with configuration collections such as system.site or core.extension appearing in the relevant list. A preview can report these names without modifying the database.

A practical preview implementation might look like this:

if (!$comparer->hasChanges()) {
  $this->output()->writeln('Configuration is already synchronised.');
  return;
}

foreach (['create', 'update', 'delete'] as $operation) {
  $items = $change_list[$operation] ?? [];

  foreach ($items as $name) {
    $this->output()->writeln(sprintf('%s: %s', strtoupper($operation), $name));
  }
}

if ($options['preview']) {
  $this->output()->writeln('Preview complete; no configuration was imported.');
  return;
}

This output is particularly useful when a release is being coordinated between a Melbourne development team and a Perth operations team. The command gives both groups a concise record of what is about to change, rather than relying on someone to interpret a large YAML diff during an afternoon deployment window.

Running The Drupal Import Safely

The importer must receive the storage comparer before it can process the change set. In Drupal versions where config.importer is available as a service, the command can use the importer and execute its synchronisation steps:

use Drupal\Core\Config\StorageComparer;

$comparer = new StorageComparer(
  $this->syncStorage,
  $this->activeStorage
);
$comparer->createChangelist();

$importer = \Drupal::service('config.importer');
$importer->setStorageComparer($comparer);

if ($importer->hasUnprocessedConfigurationChanges()) {
  foreach ($importer->getSyncSteps() as $step) {
    $importer->doSyncStep($step, $context);
  }
}

The importer API has changed between Drupal core releases, so check the method names against the core version used by the project. Some versions expose a batch-style sequence through getSyncStep() and doSyncStep(), while others use a collection of steps. The principle remains the same: create a comparer, give it to Drupal’s importer, and let Drupal process the dependency-aware operation order.

A complete production command should catch configuration import exceptions and return a non-zero process status. Drush supports DrushRuntimeException, which makes failures visible to shell scripts:

use Drush\Exceptions\DrushRuntimeException;

try {
  // Execute the version-appropriate importer steps here.
}
catch (\Throwable $exception) {
  $this->logger()->error('Configuration import failed: {message}', [
    'message' => $exception->getMessage(),
  ]);

  throw new DrushRuntimeException(
    'Configuration synchronisation failed. Review the log for details.'
  );
}

Do not catch an exception, print a friendly message, and allow the process to exit successfully. A CI job could then mark a failed deployment as successful. Returning failure clearly is essential when a command runs through GitHub Actions, GitLab CI, or a managed hosting hook.

Adding Environment Protection

A custom command is a useful place to enforce deployment policy. For example, the command can require an environment variable, refuse to run against a production host without an explicit flag, or confirm that the Git working tree contains the expected configuration commit.

A simple safeguard might be:

if (getenv('DRUPAL_CONFIG_IMPORT_ALLOWED') !== '1') {
  throw new DrushRuntimeException(
    'Configuration import is disabled in this environment.'
  );
}

A stronger implementation can inspect the current site identifier, environment name, or a settings value. Keep environment-specific behaviour in settings.php or environment variables rather than committing production secrets into configuration YAML.

Australian organisations often have formal change controls for government, health, education, and financial websites. A command that writes a preview to the deployment log supports an approval trail before a change reaches a production site in Sydney or Brisbane. For smaller agencies, the same check prevents an accidental import during an arvo debugging session.

Be careful with --yes. It should skip an interactive prompt, not skip validation. The command should still check the sync directory, compare changes, verify the environment, and allow Drupal’s importer to reject invalid configuration.

Handling Configuration Splits And Site Variations

Many projects use Configuration Split to keep environment-specific settings separate. Development may enable devel modules, local mail handling, or verbose logging, while production uses external services, restricted permissions, and different cache settings. A synchronisation command must understand which split is active before importing.

If Configuration Split is used, run the command in the same bootstrap environment as the normal deployment process. Do not assume that copying a directory from a developer laptop will reproduce the production result. The active split configuration, settings files, and deployment variables all influence the final storage comparison.

Targeted synchronisation also needs care. Importing system.site alone may seem harmless, but configuration entities can refer to fields, views, displays, roles, or modules that are part of a broader change. If the command supports a list of names, document that it is intended for controlled administrative tasks rather than a replacement for a complete deployment import.

A useful compromise is to add an allow-list for particularly sensitive objects. For example, a project might refuse to import core.extension through a partial command while allowing selected view or block configuration. Full imports remain available for normal releases, with targeted operations reserved for well-understood maintenance work.

Testing And Automating The Workflow

Test the command on a disposable database and a copy of the synchronisation directory. First run the preview mode, then test an import that creates a configuration object, updates an existing object, and deletes an object. Confirm that the active configuration matches the expected result after each operation.

Also test invalid YAML, missing module dependencies, a locked database, and a configuration entity with an invalid value. The command should fail loudly and leave a useful message in both terminal output and Drupal logs. Rebuild caches after changing the service definition or command class:

drush cr
drush site-tools:sync-config --preview
drush site-tools:sync-config --yes
drush config:status

For continuous integration, use a two-stage process. The first stage builds the codebase and validates configuration. The second stage performs the import only after tests pass. Keep the command output in the deployment artefacts so a team can audit what changed.

This approach works well for sites hosted in Australian data centres or managed platforms serving users from Hobart to Darwin. Teams can schedule production releases around traffic patterns and time-zone differences without relying on a person to run a long list of commands manually. The custom Drush command becomes a small, version-controlled part of the deployment system rather than an undocumented workstation shortcut.

Maintaining The Command Across Core Versions

Drupal configuration APIs are stable in purpose but can change in service wiring and importer method details. Pin Drupal core and Drush versions in Composer, test upgrades in a non-production environment, and review deprecation notices before updating the command.

Keep the command focused. Validation, comparison, import, and reporting belong together, while unrelated tasks such as database backups or cache purging may be better handled by separate commands. Small commands are easier to test and safer to reuse across client projects.

Document the expected directory, supported environments, required permissions, and example invocations in the module’s README file. Include whether operators should run drush updb before configuration import and whether the hosting platform already provides a deployment lock.

A well-designed command gives Drupal teams a predictable configuration workflow: compare the synchronisation storage, review the proposed changes, import through Drupal’s API, and report failures accurately. That pattern scales from a local site on a developer’s machine to a multi-environment Australian platform deployment.