TechEarl

Add a Custom WP-CLI Command in WordPress

How to register a custom WP-CLI command: guard it with defined('WP_CLI'), wire it up with WP_CLI::add_command(), turn class methods into subcommands, document args with @synopsis, and show progress with make_progress_bar().

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
How to register a custom WP-CLI command in WordPress: a command class wired up with WP_CLI::add_command(), public methods as subcommands, args and flags documented with @synopsis, and a make_progress_bar() loop for batch jobs.

To add a custom WP-CLI command, register a callable with WP_CLI::add_command() from inside code that only runs when WP-CLI is loaded. The smallest working version is a command name, a class, and one public method that becomes a subcommand:

php
<?php
if ( defined( 'WP_CLI' ) && WP_CLI ) {

    class TE_CLI_Command {

        public function hello( $args, $assoc_args ) {
            WP_CLI::success( 'Hello from a custom command.' );
        }
    }

    WP_CLI::add_command( 'te', 'TE_CLI_Command' );
}

Drop that in a plugin, run wp te hello, and you get a green success line. That is the entire mechanism. Everything below is detail on top of those three things: the guard, the class, and the add_command() call.

Guard the registration with defined( 'WP_CLI' )

The WP_CLI class only exists when code is running inside the wp binary. On a normal web request it is undefined, so calling WP_CLI::add_command() on every page load would fatal the site. The guard is not optional:

php
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    // command registration here
}

defined( 'WP_CLI' ) checks the constant exists; the && WP_CLI confirms it is truthy. WP-CLI defines and sets it before it loads your plugins, so by the time this file runs the check is already accurate. Wrapping the whole block this way means the class definition and the add_command() call are both skipped on the front end, and you never ship CLI-only code to a web request.

The command class: public methods are subcommands

WP_CLI::add_command( $name, $callable ) takes a command name and an implementation. The implementation can be a function, a closure, or a class (passed as a class-name string or an object). When you pass a class, every public method on it is registered as a subcommand of the top-level name.

So this class, registered under te:

php
class TE_CLI_Command {

    public function hello( $args, $assoc_args ) {
        WP_CLI::success( 'Hello.' );
    }

    public function status( $args, $assoc_args ) {
        WP_CLI::line( 'All good.' );
    }
}

WP_CLI::add_command( 'te', 'TE_CLI_Command' );

gives you two subcommands: wp te hello and wp te status. The method name maps directly to the subcommand name, with underscores becoming hyphens (a purge_cache() method is wp te purge-cache). One caveat from the handbook: if the class implements the magic __invoke() method, WP-CLI treats it as a single command and the other public methods stop becoming subcommands. Leave __invoke() off unless you actually want a one-shot command with no subcommands.

Args, flags, and the @synopsis annotation

Every subcommand method has the same signature: ( $args, $assoc_args ).

  • $args is the array of positional arguments, in order.
  • $assoc_args is the associative array of --key=value flags.

So wp te greet Ada --loud lands as $args[0] === 'Ada' and $assoc_args['loud'] === true:

php
public function greet( $args, $assoc_args ) {
    $name = $args[0];
    $loud = ! empty( $assoc_args['loud'] );

    $message = "Hello, {$name}.";
    if ( $loud ) {
        $message = strtoupper( $message );
    }
    WP_CLI::line( $message );
}

That works, but nothing tells the reader (or WP-CLI's own validation and --help) what this command expects. That is what the PHPDoc block above the method does. WP-CLI parses an ## OPTIONS section, and historically a @synopsis line, to define and document each argument. The structured form reads like this (it has to live in a fenced code block, not inline, because the angle brackets and braces are literal):

php
/**
 * Greet someone by name.
 *
 * ## OPTIONS
 *
 * <name>
 * : The person to greet.
 *
 * [--loud]
 * : Shout the greeting in uppercase.
 *
 * ## EXAMPLES
 *
 *     wp te greet Ada --loud
 *
 * @when after_wp_load
 */
public function greet( $args, $assoc_args ) {
    // ...
}

The bracket conventions are worth committing to memory:

AnnotationMeaning
<name>required positional argument
[<name>]optional positional argument
<name>...repeating positional (one or more)
[--flag]optional boolean flag
[--key=<value>]optional flag that takes a value

With that block in place, wp help te greet prints a real synopsis, and WP-CLI rejects a call that omits the required <name> before your method ever runs. The older one-line @synopsis: <name> [--loud] form still works, but the ## OPTIONS block is what the current handbook documents because it also feeds the per-argument descriptions and the help output.

Output helpers, and why WP_CLI::error() is different

Do not echo from a command. WP-CLI ships output helpers that respect --quiet, color the output, and route to STDERR where appropriate:

  • WP_CLI::line() writes a plain line to STDOUT. Use it for the actual data a script might pipe.
  • WP_CLI::log() is like line() but suppressed under --quiet. Use it for progress chatter.
  • WP_CLI::success() prints a green Success: line.
  • WP_CLI::warning() prints a yellow Warning: to STDERR and keeps going.
  • WP_CLI::error() prints a red Error: to STDERR and halts the command with a non-zero exit code.

That last one is the important distinction. WP_CLI::error() does not just print, it stops execution, which is exactly what you want for a precondition that makes the rest of the command meaningless:

php
public function rebuild( $args, $assoc_args ) {
    if ( ! function_exists( 'some_required_plugin_api' ) ) {
        WP_CLI::error( 'The plugin that provides the data is not active.' );
    }

    // Only reached when the precondition held.
    WP_CLI::success( 'Rebuilt.' );
}

Because error() exits non-zero, a wrapping shell script or CI job sees the failure and can stop the pipeline. warning() is the right call when something is off but the command can still finish; error() is for "there is no point continuing."

A progress bar for batch loops

The whole reason to write a command is usually to chew through a lot of rows: re-save every post, backfill a meta key, regenerate a cache. For anything that loops over more than a handful of items, give the operator a progress bar with WP_CLI\Utils\make_progress_bar().

The signature is make_progress_bar( $message, $count, $interval = 100 ): a label, the total number of ticks, and an optional redraw interval in milliseconds. The returned object has tick() to advance it by one and finish() to close it out:

php
public function backfill( $args, $assoc_args ) {
    $post_ids = get_posts( array(
        'post_type'      => 'post',
        'posts_per_page' => -1,
        'fields'         => 'ids',
    ) );

    $count    = count( $post_ids );
    $progress = WP_CLI\Utils\make_progress_bar( 'Backfilling posts', $count );

    foreach ( $post_ids as $post_id ) {
        update_post_meta( $post_id, 'te_indexed', current_time( 'mysql' ) );
        $progress->tick();
    }

    $progress->finish();
    WP_CLI::success( "Backfilled {$count} posts." );
}

That renders a live bar with percentage, elapsed time, and an ETA while it runs. One nice detail: outside a real WP-CLI context the helper returns a WP_CLI\NoOp object, so the same tick()/finish() calls are harmless no-ops if the code path is ever reached some other way. On very large jobs, pull IDs only ('fields' =&gt; 'ids'), free objects between iterations, and consider WP-CLI's own --batch style chunking so you are not holding the whole result set in memory.

Where to register it: a plugin, guarded

Put the command in a small plugin (or an mu-plugin for site infrastructure you never want deactivated). A plugin loads on both web and CLI requests, which is fine because the defined( 'WP_CLI' ) guard skips the registration on the web side. Here is the file with a proper plugin header:

php
<?php
/**
 * Plugin Name: TE CLI
 * Plugin URI:  https://techearl.com/wordpress-custom-wp-cli-command
 * Description: Example custom WP-CLI command with subcommands, args, flags, and a progress bar.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-cli
 */

if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
    return;
}

class TE_CLI_Command {

    /**
     * Print a hello line.
     *
     * @when after_wp_load
     */
    public function hello( $args, $assoc_args ) {
        WP_CLI::success( 'Hello from the te command.' );
    }
}

WP_CLI::add_command( 'te', 'TE_CLI_Command' );

Note the early return flips the guard: bail out the instant the file loads outside WP-CLI, so the class is never even declared on a web request. Either style is fine; the early return reads cleaner when the whole plugin is CLI-only.

The @when after_wp_load annotation tells WP-CLI to fully load WordPress before running the method, which is what you want whenever the command touches posts, options, or any core API. Commands that only need the autoloader (a one-off file generator, say) can run earlier, but for anything touching the database, after_wp_load is the safe default.

Verify it works

running the custom wp te hello WP-CLI command and its success output
Real output: the custom wp te command running and succeeding.

From the WordPress install root, run:

bash
wp te hello

You should see a green Success: Hello from the te command. Then confirm WP-CLI sees the subcommand and its synopsis:

bash
wp help te
wp help te hello

wp help te lists the subcommands derived from your public methods; wp help te hello shows the description and options parsed out of the PHPDoc block. If wp te hello reports an unknown command, the usual causes are the plugin not being active, the registration sitting outside the defined( 'WP_CLI' ) guard so a stray fatal killed the load, or a typo between the method name and the subcommand you typed.

Because a WP-CLI command is just a wp invocation, it slots straight into automation: a cron entry, a deploy hook, or a CI step can call wp te backfill exactly the way you ran it by hand. If you want WordPress itself to run something on a schedule rather than the system crontab, that is a separate mechanism, covered in scheduling a recurring task with WP-Cron. For the inverse pattern, having a real system cron call wp, the static sitemap generator is a worked example of a WP-CLI job wired to cron.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressWP-CLIDevOpsPHPAutomation

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Catch and Route Your Own 404s in WordPress

Intercept requests that would 404 in WordPress on template_redirect, then resolve them to real content with status_header(200), 301 to the right URL with wp_safe_redirect(), or let them fall through. A fallback router for dynamic and legacy slugs you cannot enumerate.