Introduction
WP-CLI has become the de facto standard for WordPress command-line automation. System administrators, developers, and site maintenance scripts rely on it daily to manage WordPress installations without touching the web interface. For plugin developers, WP-CLI integration represents an opportunity to provide power users with efficient automation tools.
A plugin with thoughtful WP-CLI support extends far beyond the basic WordPress dashboard. Users can automate complex workflows, integrate with deployment systems, build custom monitoring, and manage large site networks from the command line. Yet many plugins ignore WP-CLI entirely, leaving users without automation options.
Creating custom WP-CLI commands requires understanding the WP-CLI architecture, proper command registration, argument and option handling, progress reporting, and testing strategies. It's not difficult—but it requires attention to detail and following WP-CLI patterns carefully.
This comprehensive tutorial walks through everything needed to create production-ready WP-CLI commands for your plugin. From simple commands that process data to complex operations with progress tracking, you'll learn to build CLI tools that developers love using. When you combine WP-CLI support with WP HealthKit's automated security audits, you create plugins that developers trust for both functionality and safety.
Table of Contents
- WP-CLI Architecture and Basics
- Registering Custom Commands
- Argument and Option Handling
- Output Formatting and Tables
- Progress Tracking and Feedback
- Error Handling and Exit Codes
- Testing CLI Commands
- Best Practices for CLI Commands
- Frequently Asked Questions
WP-CLI Architecture and Basics
WP-CLI consists of command classes extending the WP_CLI_Command abstract class. Each public method becomes a subcommand, and the method's PHPDoc comments define arguments, options, and documentation.
Understanding the WP-CLI architecture is fundamental to building effective command-line tools for your plugin. WP-CLI operates as a runtime environment that loads WordPress and then executes your command code within that context. This means you have access to all WordPress functions, hooks, and database tools, but you're operating outside the web request cycle. This distinction is important because it changes how you handle certain concerns like authentication and output.
The command class architecture provides structure and discoverability. When you extend WP_CLI_Command and define public methods, each method automatically becomes available as a subcommand. The naming convention is important: if your class is named My_Plugin_CLI with a method process(), users will run wp my-plugin process. WP-CLI handles the conversion from camelCase/snake_case class and method names to command syntax automatically, following WordPress naming conventions.
PHPDoc comments in your command methods serve dual purposes. They provide the primary source of documentation that WP-CLI displays when users run wp my-plugin help process. They also define the contract for arguments and options, including type information, default values, and validation rules. This documentation is machine-readable, allowing WP-CLI to validate arguments before your code runs and to display helpful usage information automatically.
<?php
/**
* Manage plugin data
*/
class My_Plugin_CLI extends WP_CLI_Command {
/**
* List all plugin data records
*
* ## OPTIONS
*
* [--format=<format>]
* : Output format. Default: table
* ---
* default: table
* options:
* - table
* - json
* - csv
* ---
*
* [--limit=<number>]
* : Limit results. Default: 20
* ---
* default: 20
* ---
*
* ## EXAMPLES
*
* wp my-plugin list --limit=50
* wp my-plugin list --format=json
*
* @when after_wp_load
*/
public function list( $args, $assoc_args ) {
// Implementation here
}
}
Several key aspects of WP-CLI architecture:
Command Naming: If your command file defines My_Plugin_CLI with method list(), users run wp my-plugin list. WP-CLI converts class names and method names to command syntax automatically.
When to Load: The @when annotation tells WP-CLI when to load your command:
@when after_wp_load: After WordPress loads (default, needed for most plugin commands)@when before_wp_load: Before WordPress loads (for specific utilities)
PHPDoc Format: Arguments and options are documented in special PHPDoc blocks that WP-CLI parses for help text, default values, and validation.
Method Signature: Every command receives $args (positional arguments) and $assoc_args (options with --name=value).
WP-CLI handles the heavy lifting of parsing arguments, validating options, and formatting output. Your command code focuses on the actual logic.
Registering Custom Commands
Commands must be registered when WP-CLI initializes. The registration happens in your plugin's main file or through an include.
<?php
/**
* Plugin Name: My Plugin
* Description: A plugin with WP-CLI support
* Version: 1.0.0
*/
// Register WP-CLI commands
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once plugin_dir_path( __FILE__ ) . 'includes/class-cli.php';
WP_CLI::add_command( 'my-plugin', 'My_Plugin_CLI' );
}
The WP_CLI::add_command() function registers your command class:
WP_CLI::add_command( 'my-plugin', 'My_Plugin_CLI' );
// Creates: wp my-plugin <subcommand>
WP_CLI::add_command( 'my-plugin config', 'My_Plugin_Config_CLI' );
// Creates: wp my-plugin config <subcommand>
You can create hierarchical commands by registering multiple command classes. This organization helps users understand what your plugin can do through the command structure itself.
// Register multiple command classes for different functionality
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once plugin_dir_path( __FILE__ ) . 'includes/class-cli-data.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/class-cli-config.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/class-cli-reports.php';
WP_CLI::add_command( 'my-plugin', 'My_Plugin_CLI_Data' );
WP_CLI::add_command( 'my-plugin config', 'My_Plugin_CLI_Config' );
WP_CLI::add_command( 'my-plugin reports', 'My_Plugin_CLI_Reports' );
}
Always check that WP-CLI is available before registering commands. This prevents errors when WordPress loads outside of a CLI context.
Registration timing is critical for plugin developers. Your registration code should execute during the WordPress bootstrap process, which is why it's placed in the main plugin file or called from there. The defined( 'WP_CLI' ) && WP_CLI check ensures your registration code only runs when WP-CLI is active, preventing errors if someone accesses your plugin through the web interface.
Hierarchical command organization helps users understand your plugin's capabilities. Rather than having all commands at one level, you can create logical groupings. For example, a complex plugin might have wp my-plugin config, wp my-plugin reports, and wp my-plugin data commands, each containing their own subcommands. This structure mirrors how the plugin's functionality is organized, making it intuitive for users to discover what's available. Think of it as building a mental model of your plugin's architecture that users can explore through the command structure itself.
When registering multiple command classes, consider the logical relationships between commands. Commands that configure plugin behavior should be under a config command. Commands that export or analyze data should be under a reports command. This organization makes your CLI interface more discoverable and professional, which is especially important for plugins that provide significant command-line functionality.
Argument and Option Handling
WP-CLI passes arguments as positional values and options as named parameters. Understanding how to properly handle both is essential.
/**
* Process a specific record
*
* ## ARGUMENTS
*
* <record_id>
* : The record ID to process. Integer required.
*
* ## OPTIONS
*
* [--force]
* : Force processing even if already processed. Default: false
*
* [--format=<format>]
* : Output format. Default: table
* ---
* default: table
* options:
* - table
* - json
* ---
*
* [--notify=<email>]
* : Email address to notify when complete. Optional.
*
* ## EXAMPLES
*
* wp my-plugin process 123
* wp my-plugin process 123 --force
* wp my-plugin process 123 [email protected]
*
* @when after_wp_load
*/
public function process( $args, $assoc_args ) {
// Extract positional arguments
$record_id = $args[0];
// Validate argument
if ( ! is_numeric( $record_id ) ) {
WP_CLI::error( 'Record ID must be numeric' );
}
$record_id = (int) $record_id;
// Check if record exists
$record = $this->get_record( $record_id );
if ( ! $record ) {
WP_CLI::error( "Record $record_id not found" );
}
// Check boolean flag
$force = WP_CLI\Utils::get_flag_value( $assoc_args, 'force', false );
// Get optional value
$notify_email = isset( $assoc_args['notify'] ) ? $assoc_args['notify'] : null;
// Validate email if provided
if ( $notify_email && ! is_email( $notify_email ) ) {
WP_CLI::error( 'Invalid email address' );
}
// Process the record
try {
$result = $this->process_record( $record_id, $force );
WP_CLI::success( "Record $record_id processed successfully" );
// Send notification if requested
if ( $notify_email ) {
wp_mail( $notify_email, 'Processing Complete', "Record $record_id processed" );
WP_CLI::line( "Notification sent to $notify_email" );
}
// Output result in requested format
$format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
WP_CLI\Utils::format_items( $format, array( $result ), array( 'id', 'status' ) );
} catch ( Exception $e ) {
WP_CLI::error( $e->getMessage() );
}
}
WP-CLI provides utility functions for working with arguments:
WP_CLI\Utils::get_flag_value(): Extract boolean flagsisset( $assoc_args['option'] ): Check if option was providedWP_CLI::error(): Exit with error message and code 1WP_CLI::success(): Output success message in greenWP_CLI::line(): Output plain textWP_CLI::warning(): Output warning in yellow
Always validate arguments and options. WP-CLI doesn't automatically validate types, so your command must check that provided values are the expected type.
Validation in CLI commands is different from web form validation. In the web context, browsers and WordPress provide multiple layers of protection. In CLI contexts, your command code is the only gatekeeper. This means being defensive about what values users can pass. Even if your PHPDoc declares that a parameter must be numeric, a user could still attempt to pass text. Your code must handle this gracefully.
The approach above demonstrates several validation patterns. For positional arguments that are lists, check the array length before accessing. For numeric values, use is_numeric() before casting to int. For options that represent choices, validate against an allowed set. For email options, use WordPress's is_email() function. The earlier you catch invalid input and exit with a clear error message, the better the user experience and the safer your command execution.
Defensive coding extends to database queries. Even though WP-CLI commands run from the command line and aren't as exposed to injection attacks as web endpoints, using prepared statements should still be standard practice. The example above shows proper use of $wpdb->prepare() when constructing queries with variables. This is both a security best practice and a consistency pattern that makes your codebase more maintainable.
Output Formatting and Tables
WP-CLI provides utilities for formatted output that respects user preferences. The --format option is standard across WP-CLI.
/**
* List records with formatting
*
* ## OPTIONS
*
* [--format=<format>]
* : Output format
* ---
* default: table
* options:
* - table
* - csv
* - json
* - yaml
* - ids
* ---
*
* [--fields=<fields>]
* : Comma-separated fields to display
*
* ## EXAMPLES
*
* wp my-plugin list
* wp my-plugin list --format=json
* wp my-plugin list --fields=id,name,status
*/
public function list( $args, $assoc_args ) {
global $wpdb;
// Get records from database
$records = $wpdb->get_results(
"SELECT id, name, status, created FROM {$wpdb->prefix}my_plugin_records",
ARRAY_A
);
if ( empty( $records ) ) {
WP_CLI::error( 'No records found' );
}
// Define available fields
$all_fields = array( 'id', 'name', 'status', 'created' );
// Get requested fields
$fields = isset( $assoc_args['fields'] )
? array_map( 'trim', explode( ',', $assoc_args['fields'] ) )
: $all_fields;
// Validate requested fields
$invalid_fields = array_diff( $fields, $all_fields );
if ( $invalid_fields ) {
WP_CLI::error( 'Invalid fields: ' . implode( ', ', $invalid_fields ) );
}
// Format output
$format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
WP_CLI\Utils::format_items( $format, $records, $fields );
}
The format_items() function handles all output formatting. It supports:
- table: Human-readable table (default)
- csv: Comma-separated values
- json: JSON array
- yaml: YAML format
- ids: Just the IDs (useful for piping)
This flexibility allows users to integrate your commands into scripts and data processing pipelines.
The format_items() function is one of WP-CLI's most useful utilities because it standardizes how data output works across the entire WordPress CLI ecosystem. Users become accustomed to running commands with --format=json to get machine-readable output, --format=csv for spreadsheet imports, and --format=table for human-readable display. By supporting this standard convention, your plugin feels integrated into the broader WP-CLI toolset rather than being a standalone tool.
The --fields option provides additional flexibility. When users only need specific columns from a large result set, being able to specify --fields=id,name reduces output volume and makes the data easier to work with. This pattern is especially valuable for commands that return data with many columns. Users can view the full table with wp my-plugin list, then use specific fields when piping data to other tools.
Building filtering and sorting support into your list command makes it even more powerful. While the example above shows basic formatting, production commands often need --orderby, --order, --search, and --where parameters to allow users to precisely control which data they retrieve. This transforms your command from a simple data viewer into a flexible data query tool.
Mid-Article CTA
WP-CLI commands are powerful automation tools, but they must be implemented securely. Improper argument validation, insecure database queries, and missing permission checks in CLI code create vulnerabilities that affect your entire user base.
WP HealthKit audits your plugin's CLI command implementations to identify security gaps before they reach production. Our scans check for proper capability verification, SQL injection risks, and argument validation in every WP-CLI command.
Scan Your Plugin's CLI Code with WP HealthKit and ensure your commands are secure and properly implemented.
Progress Tracking and Feedback
Long-running commands need progress feedback. WP-CLI provides progress bars and logging.
/**
* Process all records
*
* ## OPTIONS
*
* [--batch-size=<number>]
* : Records to process per batch. Default: 100
* ---
* default: 100
* ---
*
* ## EXAMPLES
*
* wp my-plugin process-all
* wp my-plugin process-all --batch-size=50
*/
public function process_all( $args, $assoc_args ) {
global $wpdb;
$batch_size = (int) WP_CLI\Utils::get_flag_value( $assoc_args, 'batch-size', 100 );
// Count total records
$total = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}my_plugin_records WHERE status = 'pending'"
);
if ( 0 === $total ) {
WP_CLI::success( 'No records to process' );
return;
}
// Create progress bar
$progress = WP_CLI\Utils\make_progress_bar(
'Processing records',
$total,
array( 'format' => 'Processing %current%/%total% [%bar%] %elapsed:>8s%/%estimated:>8s%' )
);
$processed = 0;
$failed = 0;
// Process in batches
while ( $processed < $total ) {
$records = $wpdb->get_results(
$wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}my_plugin_records
WHERE status = 'pending'
LIMIT %d OFFSET %d",
$batch_size,
$processed
),
ARRAY_A
);
foreach ( $records as $record ) {
try {
$this->process_record( $record['id'] );
$progress->tick();
} catch ( Exception $e ) {
$failed++;
$progress->tick();
WP_CLI::warning( "Record {$record['id']}: " . $e->getMessage() );
}
}
$processed += count( $records );
}
$progress->finish();
WP_CLI::success( "Processed $total records" );
if ( $failed > 0 ) {
WP_CLI::warning( "$failed records failed" );
}
}
The progress bar provides visual feedback for long operations. Customize the format string to show what's happening.
WP-CLI also provides logging for structured output:
/**
* Detailed logging example
*/
public function detailed_operation( $args, $assoc_args ) {
WP_CLI::log( 'Starting operation...' );
// Step 1
WP_CLI::log( 'Step 1: Validating data' );
if ( ! $this->validate_data() ) {
WP_CLI::warning( 'Validation warnings detected' );
}
// Step 2
WP_CLI::log( 'Step 2: Processing...' );
$result = $this->process_data();
// Step 3
WP_CLI::log( 'Step 3: Finalizing...' );
$this->finalize();
WP_CLI::success( 'Operation completed successfully' );
}
This structured logging helps users understand what their command is doing and where it might be failing.
Implementing good logging practices in CLI commands requires thinking about what information users actually need. When a command succeeds, a simple success message is fine. But when a command fails or produces unexpected results, detailed logging becomes invaluable for troubleshooting. By logging at each major step, users can understand exactly where execution went wrong.
The logging utilities in WP-CLI support different verbosity levels implicitly. Using WP_CLI::log() for informational messages that users might want to see, WP_CLI::warning() for non-fatal issues that still complete successfully, and WP_CLI::error() for fatal issues that halt execution, you create a natural hierarchy of message severity. Users running commands for regular operation will see success/error messages. Users troubleshooting problems can check logs to understand step-by-step what happened.
Long-running operations benefit tremendously from progress indicators. When a command processes thousands of records, users need reassurance that progress is being made. The progress bar communicates how far through the operation execution has progressed, how many items remain, and estimated time to completion. This transforms what might otherwise feel like a hung process into a transparent operation that users can monitor.
Error Handling and Exit Codes
Proper error handling makes commands reliable and integrable with scripts.
/**
* Demonstrate error handling
*/
public function example_command( $args, $assoc_args ) {
// Validation error - halt execution
if ( empty( $args ) ) {
WP_CLI::error( 'Record ID is required' );
// WP_CLI::error exits with code 1
// This line never executes
}
$record_id = (int) $args[0];
// Check for existence
$record = $this->get_record( $record_id );
if ( ! $record ) {
WP_CLI::error( "Record $record_id not found" );
}
// Non-fatal warning
if ( $record['status'] === 'draft' ) {
WP_CLI::warning( 'This record is still in draft status' );
}
// Success
WP_CLI::success( 'Command completed' );
// Exit code 0 (implicit)
}
WP-CLI exit codes:
- 0: Success
- 1: General error (from
WP_CLI::error()) - 2: Usage error
Scripts can check exit codes to handle success/failure:
wp my-plugin process 123
if [ $? -eq 0 ]; then
echo "Processing succeeded"
else
echo "Processing failed"
fi
Testing CLI Commands
Testing WP-CLI commands requires the WP CLI testing framework.
<?php
/**
* Test CLI commands
*/
class Test_My_Plugin_CLI extends WP_UnitTestCase {
public function test_list_command() {
// Create test data
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'my_plugin_records',
array(
'name' => 'Test Record',
'status' => 'active',
)
);
// Run command
$result = WP_CLI::runcommand( 'my-plugin list --format=json' );
// Verify output
$items = json_decode( $result, true );
$this->assertCount( 1, $items );
$this->assertEquals( 'Test Record', $items[0]['name'] );
}
public function test_process_nonexistent_record() {
// Run command with invalid ID
$result = WP_CLI::runcommand( 'my-plugin process 999', array( 'return' => 'return_code' ) );
// Verify it fails
$this->assertNotEquals( 0, $result );
}
public function test_with_options() {
// Create test data
global $wpdb;
$id = $wpdb->insert(
$wpdb->prefix . 'my_plugin_records',
array( 'name' => 'Test', 'status' => 'pending' )
);
// Run with options
$result = WP_CLI::runcommand(
"my-plugin process $id --force --format=json",
array( 'return' => 'stdout' )
);
$output = json_decode( $result, true );
$this->assertTrue( isset( $output['status'] ) );
}
}
The WP CLI testing utilities include:
WP_CLI::runcommand(): Execute a command and capture output'return' => 'return_code': Get the exit code'return' => 'stdout': Get the output'return' => 'both': Get both code and output
Testing CLI commands is critical because these commands often interact with production data. A bug in a list command might cause incorrect reporting. A bug in a processing command might corrupt data or cause incomplete execution. Comprehensive testing provides confidence that your CLI commands are reliable.
The testing patterns shown above cover the most important scenarios. Test successful execution with various options to ensure flags work as documented. Test error conditions to verify that invalid input produces helpful error messages and correct exit codes. Test with realistic data volumes to ensure your commands scale appropriately. Many developers overlook CLI testing because they consider it less critical than web interface testing, but this is a mistake. CLI commands often process data in batch, meaning a single bug can affect many records.
Best Practices for CLI Commands
Several practices ensure your CLI commands are professional and user-friendly:
/**
* Best practices example
*/
class My_Plugin_CLI extends WP_CLI_Command {
/**
* Process records with best practices
*
* ## ARGUMENTS
*
* <record_id>
* : The record ID to process
*
* ## OPTIONS
*
* [--dry-run]
* : Show what would be done without actually doing it
*
* [--verbose]
* : Enable verbose output
*
* ## EXAMPLES
*
* # Process a single record
* wp my-plugin process 123
*
* # See what would happen
* wp my-plugin process 123 --dry-run
*
* # Get detailed output
* wp my-plugin process 123 --verbose
*
* @when after_wp_load
*/
public function process( $args, $assoc_args ) {
$record_id = (int) $args[0];
// Check permissions
if ( ! current_user_can( 'manage_options' ) ) {
WP_CLI::error( 'This command requires administrator privileges' );
}
// Get dry-run and verbose flags
$dry_run = WP_CLI\Utils::get_flag_value( $assoc_args, 'dry-run', false );
$verbose = WP_CLI\Utils::get_flag_value( $assoc_args, 'verbose', false );
// Verbose output
if ( $verbose ) {
WP_CLI::log( "Processing record $record_id" );
WP_CLI::log( "Dry-run mode: " . ( $dry_run ? 'enabled' : 'disabled' ) );
}
// Validate record exists
$record = $this->get_record( $record_id );
if ( ! $record ) {
WP_CLI::error( "Record $record_id not found" );
}
// Show what would happen
if ( $dry_run ) {
WP_CLI::line( "Would process record: {$record['name']}" );
return;
}
// Actually process
try {
$this->process_record( $record_id );
WP_CLI::success( "Processed record $record_id" );
} catch ( Exception $e ) {
WP_CLI::error( $e->getMessage() );
}
}
}
Best practices include:
- Permission Checks: Always verify the user has required capabilities
- Dry-Run Mode: Support
--dry-runto preview changes - Verbose Output: Provide
--verbosefor detailed logging - Clear Examples: Include multiple usage examples in PHPDoc
- Argument Validation: Always validate types and required values
- Graceful Errors: Provide helpful error messages
- Progress Feedback: Use progress bars for long operations
- Output Formatting: Support multiple output formats
Additional Resources
For a comprehensive view of how WP HealthKit approaches plugin analysis, explore our 17 verification layers or browse the plugin directory to see real audit scores. Ready to check your own plugin? Run a free audit now.
Frequently Asked Questions
How do I create nested subcommands?
Register different command classes at different levels. WP_CLI::add_command( 'my-plugin', 'Main_CLI' ) creates the main command, and WP_CLI::add_command( 'my-plugin config', 'Config_CLI' ) creates wp my-plugin config <subcommand>.
Should CLI commands bypass security checks?
No. Always verify permissions with current_user_can() in CLI commands. Just because something runs from the command line doesn't mean the user should be able to do everything.
How do I read input from the user interactively?
Use WP_CLI::confirm() for yes/no questions or WP_CLI::prompt() for text input. However, prefer options for scripting compatibility. Interactive prompts break automation.
Can I run database queries directly in CLI commands?
Yes, but use prepared statements to prevent SQL injection. Never concatenate user input directly into SQL queries, even in CLI commands.
What's the difference between WP_CLI::line(), WP_CLI::log(), and WP_CLI::success()?
line(): Output plain textlog(): Output informational messagessuccess(): Output in green for successwarning(): Output in yellow for warningserror(): Output in red and exit
How does WP HealthKit help with CLI command quality?
WP HealthKit audits CLI command implementations to identify security issues like missing permission checks, SQL injection risks, improper error handling, and argument validation problems. This ensures your CLI commands are safe and follow WordPress security standards.
Conclusion
WP-CLI commands extend your plugin's functionality beyond the WordPress dashboard, enabling automation and integration with deployment systems. Creating well-structured, secure CLI commands requires attention to argument handling, proper error management, progress feedback, and security verification.
By following WP-CLI patterns and best practices, you create commands that developers trust and integrate into their workflows. Users appreciate plugins with thoughtful CLI support because it enables automation and integration impossible through the web interface.
Security in CLI commands is as important as security in the web interface. Verify permissions, validate arguments, use prepared statements, and implement proper error handling in every command.
Audit your plugin's CLI commands with WP HealthKit to ensure they're secure and properly implemented. Our security scans identify permission checks, SQL injection risks, and other CLI-specific vulnerabilities before they affect users.