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
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:
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:
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 ).
$argsis the array of positional arguments, in order.$assoc_argsis the associative array of--key=valueflags.
So wp te greet Ada --loud lands as $args[0] === 'Ada' and $assoc_args['loud'] === true:
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):
/**
* 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:
| Annotation | Meaning |
|---|---|
<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 likeline()but suppressed under--quiet. Use it for progress chatter.WP_CLI::success()prints a greenSuccess:line.WP_CLI::warning()prints a yellowWarning:to STDERR and keeps going.WP_CLI::error()prints a redError: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:
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:
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' => '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
/**
* 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

From the WordPress install root, run:
wp te helloYou should see a green Success: Hello from the te command. Then confirm WP-CLI sees the subcommand and its synopsis:
wp help te
wp help te hellowp 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
- Schedule a Recurring Task with WP-Cron: when you want WordPress to run work on its own pseudo-cron rather than calling
wpfrom the system crontab, this is the mechanism to reach for - Generate a Static XML Sitemap with WP-CLI: a real WP-CLI command driven by a system cron job, a useful template for any batch task you would write as a custom command
- AI-Assisted WP-CLI Workflows: patterns for building and chaining WP-CLI commands faster, once you are comfortable writing your own
- Clean Up wp_head in WordPress: another case for a small must-use plugin, the same place a CLI-only command class fits naturally on a developer-controlled site
Sources
Authoritative references this article was fact-checked against.
- Commands Cookbook: WP-CLI Handbookmake.wordpress.org
- WP_CLI::add_command(): WP-CLI Handbook internal APImake.wordpress.org
- WP_CLI\Utils\make_progress_bar(): WP-CLI Handbook internal APImake.wordpress.org





