Foundation WP-CLI provides a container-aware 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.
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:
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.
WPCliProvider creates the shared CommandPrefix from configuration. Feature providers only add their commands to the shared collection.
In src/Catalog/Provider.php:
Provider.php
<?php declare(strict_types=1);namespace YourPlugin\Catalog;use lucatume\DI52\Container as C;use StellarWP\Foundation\Container\Contracts\Provider as Service_Provider;use StellarWP\Foundation\WPCli\WPCliProvider;use YourPlugin\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.
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:
Sync_Catalog_Command.php
<?php declare(strict_types=1);namespace YourPlugin\Catalog\Cli;use StellarWP\Foundation\Container\Contracts\Container;use StellarWP\Foundation\WPCli\Command;use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix;use WP_CLI;use YourPlugin\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( Container $container, CommandPrefix $commandPrefix, private readonly Catalog_Synchronizer $synchronizer ) { parent::__construct( $container, $commandPrefix ); } /** * @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:
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:
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.