WP_CLI add_command: Write a Custom WP-CLI Command

Register a custom WP-CLI command with WP_CLI::add_command, document its arguments in the docblock, and make it safe to run on production: dry run first.

There is a task you do in WordPress over and over. Recalculate a value on every post. Clean up a meta key a plugin left behind. Re-run an import for one category. Each time it is either a lot of clicking or a throwaway PHP file you are nervous to leave on the server.

The short answer: write the task once as a PHP callable, register it with WP_CLI::add_command( 'name', $callable ) from a plugin, and load that file only when WP_CLI is defined. WP-CLI then runs it with WordPress fully loaded, passes your positional arguments in $args and your --flags in $assoc_args, and builds wp help output from your docblock. Then make the command safe: a dry-run mode, a confirmation before it writes, and a backup before you run it anywhere that matters.

This article follows the WP-CLI handbook, read 2026-09-11. The latest WP-CLI release listed on the project’s GitHub releases page that day was 2.12.0.

The registration call

The handbook’s reference for WP_CLI::add_command gives the usage:

WP_CLI::add_command( $name, $callable, $args = [] )
  • $name is the command as typed, and it can contain spaces for subcommands; the reference’s own examples are "post list" and "site empty".
  • $callable is “a class, function or closure.”
  • $args is optional and accepts keys such as before_invoke, after_invoke, shortdesc, synopsis and when.

It returns true on success, false if registration was deferred, and raises a hard error if registration failed.

The reference’s minimal example:

$foo = function( $args, $assoc_args ) {
    WP_CLI::success( $args[0] . ' ' . $assoc_args['append'] );
};
WP_CLI::add_command( 'foo', $foo );

Run as wp foo hello --append=world, it prints a success line reading hello world.

Functions, classes and closures

The Commands Cookbook (read 2026-09-11) shows all three forms. A function:

function foo_command( $args ) {
    WP_CLI::success( $args[0] );
}
WP_CLI::add_command( 'foo', 'foo_command' );

A class with __invoke:

class Foo_Command {
    public function __invoke( $args ) {
        WP_CLI::success( $args[0] );
    }
}
WP_CLI::add_command( 'foo', 'Foo_Command' );

Use a class once you have more than one related operation. Each public method becomes a subcommand, which keeps wp acme reindex and wp acme cleanup together under one name.

Where the file goes

The command code does not need to load on normal web requests. The Cookbook’s pattern:

if ( defined( 'WP_CLI' ) && WP_CLI ) {
    require_once dirname( __FILE__ ) . '/inc/class-plugin-cli-command.php';
}

Put that in your plugin’s main file. The command ships and deploys with the plugin, it is in version control, and it never runs for a visitor.

By the time your callable runs, WP-CLI has loaded WordPress, including plugins, the active theme and init. That is why you can call get_posts() and update_post_meta() freely. How a WP-CLI run lines up against the normal load sequence is easier to reason about once you know that sequence, set out in the WordPress execution order.

The docblock is the interface

WP-CLI reads your PHPDoc to learn which arguments the command accepts, validates them before your code runs, and prints them in wp help. The Cookbook’s synopsis tokens:

TokenMeaning
<name>required positional argument
<name>...one or more positional arguments
[<name>]optional positional argument
[--flag]optional boolean flag
[--assoc=<value>]optional associative argument with a value
[--field[=<value>]]optional argument usable with or without a value

Its example, including a default and an allowed-values list:

/**
 * Prints a greeting.
 *
 * ## OPTIONS
 *
 * <name>
 * : The name of the person to greet.
 *
 * [--type=<type>]
 * : Whether to greet with success or error.
 * ---
 * default: success
 * options:
 *   - success
 *   - error
 * ---
 */
function hello( $args, $assoc_args ) {
    list( $name ) = $args;
    $type = $assoc_args['type'];
    WP_CLI::$type( "Hello, $name!" );
}

Declaring the default in the docblock means $assoc_args['type'] is always set when your code runs, so you do not need to guard it yourself.

Output that tells the truth

The Cookbook lists the output helpers:

  • WP_CLI::success( $message ) for a green success line,
  • WP_CLI::warning( $message ) for a yellow warning that continues,
  • WP_CLI::log( $message ) for informational output,
  • WP_CLI::error( $message ), which prints in red and exits,
  • WP_CLI::confirm( $question ) for a yes/no prompt.

That error exits matters: it is how a command fails loudly and returns a non-zero status to a shell script or a deployment pipeline. Use it for anything that should stop the run, and warning for a single item you can skip.

A command built to be safe on production

Here is a complete command, composed from the documented APIs above and standard WordPress functions. It recalculates a reading-time meta value on every post of a type. It defaults to doing nothing destructive: with --dry-run it only reports, and without it, it asks before writing.

/**
 * Recalculates the reading-time meta value for posts.
 *
 * ## OPTIONS
 *
 * [--post_type=<post_type>]
 * : Post type to process.
 * ---
 * default: post
 * ---
 *
 * [--dry-run]
 * : Report what would change without writing anything.
 *
 * [--yes]
 * : Skip the confirmation prompt.
 */
function acme_reading_time_command( $args, $assoc_args ) {
    $dry_run = isset( $assoc_args['dry-run'] );

    $ids = get_posts( array(
        'post_type'      => $assoc_args['post_type'],
        'post_status'    => 'any',
        'posts_per_page' => -1,
        'fields'         => 'ids',
    ) );

    if ( empty( $ids ) ) {
        WP_CLI::error( 'No posts found for that post type.' );
    }

    if ( ! $dry_run ) {
        WP_CLI::confirm( sprintf( 'Update %d posts?', count( $ids ) ), $assoc_args );
    }

    $progress = \WP_CLI\Utils\make_progress_bar( 'Processing', count( $ids ) );
    $changed  = 0;

    foreach ( $ids as $id ) {
        $content = get_post_field( 'post_content', $id );
        $minutes = (int) ceil( str_word_count( wp_strip_all_tags( $content ) ) / 200 );

        if ( (int) get_post_meta( $id, 'acme_reading_time', true ) !== $minutes ) {
            $changed++;
            if ( ! $dry_run ) {
                update_post_meta( $id, 'acme_reading_time', $minutes );
            }
        }
        $progress->tick();
    }

    $progress->finish();
    WP_CLI::success( sprintf( '%d posts %s.', $changed, $dry_run ? 'would change' : 'updated' ) );
}
WP_CLI::add_command( 'acme reading-time', 'acme_reading_time_command' );

The design choices, each tied to the docs:

  • WP_CLI::confirm() receives $assoc_args. The confirm reference (read 2026-09-11) says the prompt is skipped if yes is provided, so --yes lets a script run it unattended while a human at the keyboard still gets asked.
  • The progress bar comes from make_progress_bar (read 2026-09-11), which the handbook notes is written to STDOUT and disabled when the command is piped.
  • Only IDs are fetched. Loading full post objects for every row of a large site is the fastest way to hit the memory ceiling; if you do, the Allowed memory size exhausted fix explains what the limit is and why raising it is not the first move.
  • It only writes when the value differs, so running it twice is harmless. A command you can safely re-run after an interruption is worth more than one that is slightly faster.

Back up, then dry-run, then run

Before running any command that writes to a production database, export a backup. WP-CLI’s own wp db export page (read 2026-09-11) shows the basic form:

$ wp db export --add-drop-table
Success: Exported to 'wordpress_dbase-db72bb5.sql'.

Move that file off the server, or at least out of the web root. Then:

wp acme reading-time --dry-run
wp acme reading-time

Read the dry-run count before you run the real thing. If it says 40,000 and you expected 400, stop.

On a multisite network, WP-CLI’s global parameters (read 2026-09-11) include --url=<url>, described as “how the target site is specified.” Without it you are operating on the main site.

When the command fails

A fatal inside your command prints a PHP error and a stack trace in the terminal, which is often more readable than the web version. Reading a stack trace you did not write covers how to find your own frame in it. The single most common one in commands like this is calling a method on something a lookup returned as null, such as a post ID that no longer exists, which is exactly the case in Call to a member function on null. Check the result of every lookup before you use it.

If a plugin’s own fatal error stops WP-CLI from loading at all, the global --skip-plugins parameter loads WordPress without plugins. The config reference notes that mu-plugins are still loaded. Your command lives in a plugin, so this is for diagnosing the environment, not for running your command.

FAQ

How do I register a custom WP-CLI command? Call WP_CLI::add_command( 'name', $callable ) with a function, closure or class, from a file that loads only when defined( 'WP_CLI' ) && WP_CLI is true. The WP-CLI handbook’s Commands Cookbook shows all three forms.

How does my command receive arguments? The callable receives two arrays: $args for positional arguments and $assoc_args for --key=value arguments and flags. Declare them in the docblock synopsis and WP-CLI validates them and fills in defaults before your code runs.

How do I add a confirmation prompt that scripts can skip? Call WP_CLI::confirm( $question, $assoc_args ). Per the WP-CLI handbook, passing yes in $assoc_args, which the --yes flag does, skips the prompt.

Should a custom command live in functions.php? Put it in a plugin, loaded conditionally on WP_CLI. That keeps it in version control, deployable, and independent of the active theme.

Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *