Tag: Developer Tools

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

    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.