If you’ve ever found yourself running repetitive admin tasks through the WordPress dashboard — bulk-updating post meta, cleaning up orphaned data, triggering migrations — there’s a better way. You can build a WP-CLI custom command directly inside your plugin and run it from the terminal in seconds.
This guide walks you through the full process: registering a command, handling arguments and options, and adding a progress bar so you know exactly where things stand during long operations.
TL;DR
- Use
WP_CLI::add_command()to register a custom command inside your plugin. - Define a class with an
__invokemethod (for simple commands) or named methods (for subcommands). - Accept positional arguments and associative options via the method’s
$argsand$assoc_argsparameters. - Use
\WP_CLI\Utils\make_progress_bar()to show progress during batch operations. - Always guard your CLI code with
defined( 'WP_CLI' ) && WP_CLIso it only loads in CLI context.
Why Build a WP-CLI Custom Command?
The WordPress admin UI is fine for one-off tasks. But when you need to process thousands of posts, run a data migration, or automate something in a deployment pipeline, clicking buttons doesn’t scale.
A WP-CLI custom command lets you:
- Automate repetitive tasks from the terminal or CI/CD.
- Process large datasets without hitting PHP timeout limits.
- Ship developer-facing tools alongside your plugin.
- Keep dangerous operations out of the admin UI entirely.
If your plugin does anything non-trivial with data, it probably deserves a CLI command.
Prerequisites
Before you start, make sure you have:
- A working WordPress installation with WP-CLI installed.
- A plugin (even a simple one) where you’ll add the command.
- Familiarity with PHP classes and basic terminal usage.
Verify WP-CLI is working:
wp --version
You should see something like WP-CLI 2.x.x.
Step 1: Register Your Command With WP_CLI::add_command
The entry point for any WP-CLI custom command is WP_CLI::add_command(). This function maps a command name to a PHP class or callable.
Create a file in your plugin — for example, includes/class-cli-commands.php:
<?php/** * WP-CLI commands for My Plugin. */if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return;}class My_Plugin_CLI_Command { /** * Greets the user. * * ## EXAMPLES * * wp myplugin greet Ankit * * @when after_wp_load */ public function greet( $args, $assoc_args ) { $name = $args[0] ?? 'World'; WP_CLI::success( "Hello, {$name}!" ); }}WP_CLI::add_command( 'myplugin', 'My_Plugin_CLI_Command' );
Then load this file from your main plugin file:
// my-plugin.phpif ( defined( 'WP_CLI' ) && WP_CLI ) { require_once __DIR__ . '/includes/class-cli-commands.php';}
Now test it:
wp myplugin greet Ankit# Output: Success: Hello, Ankit!
That’s the basic pattern. The class method name (greet) becomes the subcommand. The first argument to WP_CLI::add_command() (myplugin) is the top-level namespace.
[Internal Link: “Getting started with WordPress plugin development” -> /wordpress-plugin-development-guide/]
Step 2: Handle WP-CLI Arguments and Options
Every WP-CLI command method receives two parameters:
$args— an indexed array of positional arguments.$assoc_args— an associative array of named options (flags).
Here’s how users pass them:
wp myplugin process 42 --dry-run --batch-size=100
In this example:
$args[0]is42(positional).$assoc_args['dry-run']istrue(flag).$assoc_args['batch-size']is100(named option).
Document Your Arguments With PHPDoc
WP-CLI parses a special docblock format to generate help text and validate input. This is strongly recommended:
/** * Processes posts by type. * * ## OPTIONS * * <post_type> * : The post type to process. * * [--batch-size=<number>] * : How many posts to process per batch. Default: 50. * * [--dry-run] * : Preview changes without writing to the database. * * ## EXAMPLES * * wp myplugin process page --batch-size=100 * wp myplugin process post --dry-run * * @when after_wp_load */public function process( $args, $assoc_args ) { $post_type = $args[0]; $batch_size = (int) ( $assoc_args['batch-size'] ?? 50 ); $dry_run = isset( $assoc_args['dry-run'] ); WP_CLI::log( "Processing {$post_type} posts in batches of {$batch_size}..." ); if ( $dry_run ) { WP_CLI::warning( 'Dry run enabled. No changes will be saved.' ); } // Your logic here...}
Now wp help myplugin process shows full usage documentation — automatically.
Angle brackets (<post_type>) mark required arguments. Square brackets ([--batch-size=<number>]) mark optional ones. This is standard WP-CLI synopsis format.
Step 3: Add a WP-CLI Progress Bar
When your command processes hundreds or thousands of items, a progress bar tells the user what’s happening instead of leaving them staring at a frozen terminal.
WP-CLI ships with a built-in helper: \WP_CLI\Utils\make_progress_bar().
Here’s a complete example that ties everything together:
/** * Regenerates SEO meta for all posts of a given type. * * ## OPTIONS * * <post_type> * : The post type to process. * * [--batch-size=<number>] * : Posts per batch. Default: 50. * * [--dry-run] * : Run without saving changes. * * ## EXAMPLES * * wp myplugin regenerate-meta post * wp myplugin regenerate-meta page --batch-size=200 --dry-run * * @when after_wp_load */public function regenerate_meta( $args, $assoc_args ) { $post_type = $args[0]; $batch_size = (int) ( $assoc_args['batch-size'] ?? 50 ); $dry_run = isset( $assoc_args['dry-run'] ); // Count total posts. $count_query = new WP_Query( [ 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, ] ); $post_ids = $count_query->posts; $total = count( $post_ids ); if ( 0 === $total ) { WP_CLI::warning( "No published {$post_type} posts found." ); return; } WP_CLI::log( "Found {$total} posts. Processing..." ); if ( $dry_run ) { WP_CLI::warning( 'Dry run enabled.' ); } // Create the progress bar. $progress = \WP_CLI\Utils\make_progress_bar( "Regenerating meta", $total ); $updated = 0; foreach ( $post_ids as $post_id ) { if ( ! $dry_run ) { // Your actual processing logic. $meta_value = generate_seo_meta_for( $post_id ); update_post_meta( $post_id, '_seo_description', $meta_value ); $updated++; } $progress->tick(); } $progress->finish(); WP_CLI::success( "Done. {$updated} posts updated." );}
When you run this, you get a clean progress bar in the terminal:
Regenerating meta 50% [========================> ] 250/500
Progress Bar Tips
- Always call
$progress->finish()when done — even if you exit early. - The first argument to
make_progress_bar()is the label shown to the left. - For very large datasets, process in batches using
LIMIT/OFFSETqueries instead of loading all IDs into memory at once.
Step 4: Register Multiple Subcommands
A single class can hold many subcommands. Each public method becomes a subcommand:
class My_Plugin_CLI_Command { public function greet( $args, $assoc_args ) { /* ... */ } public function process( $args, $assoc_args ) { /* ... */ } public function regenerate_meta( $args, $assoc_args ) { /* ... */ } public function cleanup( $args, $assoc_args ) { /* ... */ }}WP_CLI::add_command( 'myplugin', 'My_Plugin_CLI_Command' );
This gives you:
wp myplugin greetwp myplugin processwp myplugin regenerate-metawp myplugin cleanup
Note: underscores in method names are automatically converted to hyphens in the CLI.
[Internal Link: “Organizing plugin code with PHP classes” -> /wordpress-plugin-php-classes/]
Common Mistakes
1. Not guarding CLI code with the WP_CLI constant.
If you skip the defined( 'WP_CLI' ) && WP_CLI check, your CLI class file will throw fatal errors on normal web requests when WP-CLI isn’t loaded.
2. Using echo instead of WP_CLI::log().
Always use WP-CLI’s output methods (WP_CLI::log(), WP_CLI::success(), WP_CLI::warning(), WP_CLI::error()). They respect --quiet and --format flags. Plain echo doesn’t.
3. Calling WP_CLI::error() without understanding it halts execution.WP_CLI::error() prints the message and exits with code 1. It does not return. If you need a non-fatal warning, use WP_CLI::warning() instead.
4. Loading all posts into memory at once.
For large sites, querying all post IDs into a single array can exhaust memory. Use --batch-size with offset-based queries for production commands.
5. Forgetting the @when after_wp_load annotation.
Without this, your command may run before WordPress is fully loaded, causing undefined function errors for things like get_posts() or update_post_meta().
6. Not adding docblock synopsis for arguments.
Without the ## OPTIONS docblock, WP-CLI can’t validate input or generate help text. Users won’t know what arguments your command accepts.
FAQ
Can I add a WP-CLI command without a plugin?
Yes. You can add commands via a wp-cli.yml file or a custom PHP file loaded with --require. But for distributable tools, packaging commands inside a plugin is the standard approach.
How do I make an argument required?
Use angle brackets in the docblock synopsis: <post_type>. WP-CLI will show an error if the user omits it.
Can I use WP-CLI commands in cron jobs or CI/CD?
Absolutely. That’s one of the biggest advantages. Just call wp myplugin your-command in your shell script or pipeline. Add --quiet to suppress non-error output.
Does the progress bar work in non-interactive environments?
WP-CLI detects non-interactive terminals (like piped output or CI logs) and degrades gracefully. The progress bar won’t break anything — it just won’t render the animated bar.
How do I test my WP-CLI command?
WP-CLI has a testing framework based on Behat. For simpler setups, you can write PHPUnit tests that call your command class methods directly, passing mock $args and $assoc_args arrays.
Can I use namespaced classes with WP_CLI::add_command?
Yes. Pass the fully qualified class name as a string: WP_CLI::add_command( 'myplugin', 'MyPlugin\\CLI\\Commands' );
Checklist
[ ] WP-CLI is installed and working (wp --version).
[ ] CLI class file is guarded with defined( 'WP_CLI' ) && WP_CLI.
[ ] CLI file is loaded conditionally in your main plugin file.
[ ] Command is registered with WP_CLI::add_command().
[ ] Each subcommand method accepts $args and $assoc_args.
[ ] Arguments and options are documented in the PHPDoc ## OPTIONS block.
[ ] @when after_wp_load is set for commands that use WordPress functions.
[ ] Long-running commands include a progress bar via make_progress_bar().
[ ] Output uses WP_CLI::log(), WP_CLI::success(), etc. — not echo.
[ ] Command tested locally with wp myplugin <subcommand> --help.
Leave a Reply