How to Build a WP-CLI Custom Command in a Plugin (With Arguments + 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.

Got a WordPress project?

If you are scaling WordPress and need senior engineering, send a short note.

Discover more from Ankit Panchal

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

Continue reading