Skip to content

WP-CLI

Foundation WP-CLI provides a command base class and a shared provider for registering commands during WP-CLI bootstrap. Application services remain injectable, command prefixes remain configurable, and feature providers can contribute commands without loading WP-CLI classes during normal WordPress requests.

Install WP-CLI support as a production dependency when the plugin ships commands:

composer require stellarwp/foundation-wpcli

WP-CLI supplies the WP_CLI and WP_CLI_Command runtime classes. Applications running commands through WP-CLI do not normally need to install wp-cli/wp-cli separately.

Install the developer CLI only when the team wants to generate command classes:

composer require --dev stellarwp/foundation-cli

The generator is development tooling and does not need to ship in a standalone plugin archive.

WP-CLI commands use the same container, configuration, and provider graph as the rest of the application:

In the root config.php, configure a stable application prefix for a distributable plugin:

<?php declare(strict_types=1);

return [
	'foundation' => [
		'prefix' => 'your-plugin',
	],
];

Commands will be registered beneath wp your-plugin. Complete WordPress applications that own the full installation can keep the zero-configuration nx default.

Set wpcli.command_prefix in the same root config.php only when WP-CLI should intentionally use a different prefix:

return [
	'foundation' => [
		'prefix' => 'your-plugin',
	],
	'wpcli' => [
		'command_prefix' => 'your-plugin-tools',
	],
];

In src/App.php, register WPCliProvider before feature providers that contribute commands:

use StellarWP\Foundation\Container\Contracts\Provider;
use StellarWP\Foundation\WPCli;
use Plugin\Catalog;

/** @var list<class-string<Provider>> */
private const array PROVIDERS = [
	WPCli\WPCliProvider::class,
	Catalog\Provider::class,
];

WPCliProvider listens to cli_init and resolves the command collection only when WP-CLI is active. Feature providers should contribute commands to that collection instead of registering their own cli_init hooks.

Contribute a command from its feature provider

Section titled “Contribute a command from its feature provider”

WPCliProvider creates the command registration context from configuration. Feature providers only add their commands to the shared collection.

In src/Catalog/Provider.php:

<?php declare(strict_types=1);

namespace Plugin\Catalog;

use StellarWP\Foundation\Container\Contracts\Resolver as C;
use StellarWP\Foundation\Container\Contracts\Provider as Service_Provider;
use StellarWP\Foundation\WPCli\WPCliProvider;
use Plugin\Catalog\Cli\Sync_Catalog_Command;

/**
 * Configures the product catalog feature and its WP-CLI commands.
 */
final class Provider extends Service_Provider {

	public function register(): void {
		$this->register_cli_commands();
	}

	private function register_cli_commands(): void {
		$this->container->mergeArrayVar(
			WPCliProvider::COMMANDS,
			static fn ( C $c ): array => [
				$c->get( Sync_Catalog_Command::class ),
			]
		);
	}
}

Add more commands to the same returned array or contribute them from other feature providers. WPCliProvider validates the complete collection before registering any command.

Generate the initial class from the project root:

vendor/bin/foundation make:wpcli-command Sync_Catalog \
  --namespace="Plugin\\Catalog\\Cli" \
  --subcommand="catalog:sync" \
  --description="Synchronize the product catalog."

The generator uses Composer’s PSR-4 mapping to write src/Catalog/Cli/Sync_Catalog_Command.php. It creates a Snake_Case class with examples of a positional argument, associative option, and flag.

Projects using Strauss receive the configured namespace prefix on generated Foundation imports. With update_call_sites=false, handwritten provider imports may also need the project’s Strauss prefix.

Project-specific command stubs can override the default at foundation/stubs/wpcli/command.stub.

Keep the command focused on input, output, and selecting the application operation. Inject the service that owns the business behavior rather than resolving it from the container.

In src/Catalog/Cli/Sync_Catalog_Command.php:

<?php declare(strict_types=1);

namespace Plugin\Catalog\Cli;

use StellarWP\Foundation\WPCli\Command;
use WP_CLI;
use Plugin\Catalog\Catalog_Synchronizer;

use function WP_CLI\Utils\get_flag_value;

/**
 * Synchronizes the product catalog from a configured source.
 *
 * @example wp your-plugin catalog:sync staging
 * @example wp your-plugin catalog:sync staging --batch-size=50 --dry-run
 */
final class Sync_Catalog_Command extends Command {

	private const string ARG_SOURCE         = 'source';
	private const string OPTION_BATCH_SIZE  = 'batch-size';
	private const int DEFAULT_BATCH_SIZE    = 100;
	private const string FLAG_DRY_RUN       = 'dry-run';

	public function __construct(
		private readonly Catalog_Synchronizer $synchronizer
	) {
	}

	/**
	 * @param list<mixed>         $args
	 * @param array<string, mixed> $assocArgs
	 *
	 * @throws \WP_CLI\ExitException When command input is invalid.
	 */
	public function runCommand( array $args = [], array $assocArgs = [] ): int {
		$source    = (string) ( $args[0] ?? '' );
		$batchSize = absint( get_flag_value(
			$assocArgs,
			self::OPTION_BATCH_SIZE,
			self::DEFAULT_BATCH_SIZE
		) );
		$dryRun = (bool) get_flag_value( $assocArgs, self::FLAG_DRY_RUN, false );

		if ( $batchSize < 1 ) {
			WP_CLI::error( __( 'The batch size must be greater than zero.', 'your-plugin' ) );
		}

		$count = $this->synchronizer->sync( $source, $batchSize, $dryRun );

		if ( $dryRun ) {
			WP_CLI::success( sprintf(
				/* translators: 1: Product count, 2: Catalog source. */
				__( 'Dry run found %1$d products to synchronize from %2$s.', 'your-plugin' ),
				$count,
				$source
			) );

			return self::SUCCESS;
		}

		WP_CLI::success( sprintf(
			/* translators: 1: Product count, 2: Catalog source. */
			__( 'Synchronized %1$d products from %2$s.', 'your-plugin' ),
			$count,
			$source
		) );

		return self::SUCCESS;
	}

	protected function subcommand(): string {
		return 'catalog:sync';
	}

	protected function description(): string {
		return __( 'Synchronize the product catalog.', 'your-plugin' );
	}

	protected function arguments(): array {
		return [
			[
				'type'        => self::POSITIONAL,
				'name'        => self::ARG_SOURCE,
				'description' => __( 'The catalog source to synchronize.', 'your-plugin' ),
				'optional'    => false,
			],
			[
				'type'        => self::ASSOCIATIVE,
				'name'        => self::OPTION_BATCH_SIZE,
				'description' => __( 'The number of products processed per batch.', 'your-plugin' ),
				'optional'    => true,
				'default'     => self::DEFAULT_BATCH_SIZE,
			],
			[
				'type'        => self::FLAG,
				'name'        => self::FLAG_DRY_RUN,
				'description' => __( 'Preview the synchronization without writing changes.', 'your-plugin' ),
				'optional'    => true,
			],
		];
	}
}

The three synopsis types map to WP-CLI input as follows:

Type Declaration Invocation
Positional source staging
Associative batch-size --batch-size=50
Flag dry-run --dry-run

The command constructor contains only its business dependencies. Foundation supplies the configured prefix later, when WPCliProvider registers the command during cli_init.

Extend Command for Foundation’s synopsis, prompting, and exit-status behavior. A different command abstraction can instead implement StellarWP\Foundation\WPCli\Contracts\RegistrableCommand; its register( CommandContext $context ) method can use $context->name( 'catalog:sync' ) to build the configured command name before calling WP_CLI::add_command().

With the your-plugin application prefix, run:

wp your-plugin catalog:sync staging --batch-size=50 --dry-run

Inspect the generated synopsis and description with:

wp help your-plugin catalog:sync

Test business behavior outside the command

Section titled “Test business behavior outside the command”

Keep most tests on Catalog_Synchronizer and its collaborators. The command should contain only input normalization, application service invocation, and WP-CLI output behavior.

Use the Codeception wpcli suite for one end-to-end test that proves the provider contribution, command prefix, arguments, output, and exit code work together.

In tests/wpcli/Catalog/SyncCatalogCest.php:

<?php declare(strict_types=1);

use PHPUnit\Framework\Assert;

final class SyncCatalogCest {

	public function test_it_previews_the_catalog_sync( WPCLITester $I ): void {
		$I->cli( [
			'your-plugin',
			'catalog:sync',
			'staging',
			'--batch-size=50',
			'--dry-run',
		] );

		$I->seeResultCodeIs( 0 );
		$I->seeInShellOutput( 'Dry run found' );
	}

	public function test_it_rejects_an_invalid_batch_size( WPCLITester $I ): void {
		$I->cli( [
			'your-plugin',
			'catalog:sync',
			'staging',
			'--batch-size=0',
		] );

		$I->seeResultCodeIs( 1 );
		Assert::assertStringContainsString(
			'The batch size must be greater than zero.',
			$I->grabLastShellErrorOutput()
		);
	}
}

Run the suite through SLIC:

slic run wpcli

One real command test is more valuable than duplicating the Foundation command wrapper’s generic registration tests throughout the application.

The 1.x Command constructor accepted the Foundation Container and a CommandPrefix. Remove both arguments and the corresponding parent::__construct() call. Command constructors should contain only their application dependencies. If a command previously called $this->container->get(), inject that resolved service directly instead.

WPCliProvider now supplies a CommandContext when it registers contributed commands. Let the provider register commands through WPCliProvider::COMMANDS; direct calls to register() would need to supply the context themselves. If an application command overrides the protected command() method, either remove that override or update it to command( CommandContext $context ): string and use the context to build the configured name.

Custom command abstractions contributed to WPCliProvider::COMMANDS must implement RegistrableCommand instead of being required to extend Foundation’s base Command.