How to Build a WP-CLI Custom Command in a Plugin (With Arguments + Progress Bar)

Terminal window showing a WP-CLI custom command running with a progress bar

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 __invoke method (for simple commands) or named methods (for subcommands).
  • Accept positional arguments and associative options via the method’s $args and $assoc_args parameters.
  • Use \WP_CLI\Utils\make_progress_bar() to show progress during batch operations.
  • Always guard your CLI code with defined( 'WP_CLI' ) && WP_CLI so 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.php
if ( 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] is 42 (positional).
  • $assoc_args['dry-run'] is true (flag).
  • $assoc_args['batch-size'] is 100 (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/OFFSET queries 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 greet
wp myplugin process
wp myplugin regenerate-meta
wp 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.


Discover more from WPAnkit

Subscribe to get the latest posts sent to your email.


Comments

Leave a Reply

Discover more from WPAnkit

Subscribe now to keep reading and get access to the full archive.

Continue reading

Discover more from WPAnkit

Subscribe now to keep reading and get access to the full archive.

Continue reading