Building Custom WP-CLI Commands for Plugin Operations

WP-CLI custom commands let you expose any plugin functionality as a command-line operation — useful for batch migrations, database repairs, scheduled tasks, and deployment scripts. A well-written WP-CLI command is faster, more scriptable, and more testable than a WordPress admin page.

Problem: Repetitive WP-CLI tasks — resetting a staging environment, exporting a subset of posts, running a multi-step database migration — require either writing shell scripts that call multiple wp commands or running each step manually.

Solution: Register a custom WP-CLI command by adding a class that extends WP_CLI_Command and decorating its methods with @subcommand and @synopsis PHPDoc annotations. Register it with WP_CLI::add_command('my-plugin', 'My_CLI_Command') in a file loaded only when defined('WP_CLI') returns true.

The examples below register a custom WP-CLI command class, add subcommands with argument and flag parsing, and show how to output progress bars and formatted tables for batch operations.

]
     * : How many products to process per API page.
     * ---
     * default: 100
     * ---
     *
     * [--status=]
     * : Only sync products with this post status.
     * ---
     * default: publish
     * options:
     *   - publish
     *   - draft
     * ---
     *
     * ## EXAMPLES
     *
     *     wp myplugin sync products --dry-run
     *
     * @subcommand products
     */
    public function sync( array $args, array $assoc_args ): void {
        $dry_run = WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );
        $batch   = absint( $assoc_args['batch'] ?? 100 );
        $status  = sanitize_key( $assoc_args['status'] ?? 'publish' );

        WP_CLI::log( "Starting sync (batch={$batch}, status={$status}" . ( $dry_run ? ', DRY RUN' : '' ) . ')' );

        $products = $this->fetch_products( $status, $batch );
        $progress = WP_CLI\Utils\make_progress_bar( 'Syncing products', count( $products ) );

        $updated = 0;
        $skipped = 0;
        foreach ( $products as $product_data ) {
            if ( ! $dry_run ) {
                $this->upsert_product( $product_data );
                $updated++;
            } else {
                WP_CLI::debug( "Would update: {$product_data['sku']}" );
                $skipped++;
            }
            $progress->tick();
        }

        $progress->finish();
        WP_CLI::success( "Done. Updated: {$updated}, Skipped (dry-run): {$skipped}" );
    }
}

WP_CLI::add_command( 'myplugin sync', 'MyPlugin_CLI' );

Output structured data as a formatted table:

]
 * : Output format.
 * ---
 * default: table
 * options:
 *   - table
 *   - csv
 *   - json
 * ---
 */
public function list_products( array $args, array $assoc_args ): void {
    $format = $assoc_args['format'] ?? 'table';

    $rows = [];
    $products = wc_get_products( [ 'limit' => 50, 'status' => 'publish' ] );
    foreach ( $products as $product ) {
        $rows[] = [
            'ID'          => $product->get_id(),
            'SKU'         => $product->get_sku() ?: '—',
            'Name'        => $product->get_name(),
            'Last Synced' => get_post_meta( $product->get_id(), '_last_synced', true ) ?: 'never',
            'Status'      => $product->get_status(),
        ];
    }

    WP_CLI\Utils\format_items( $format, $rows, [ 'ID', 'SKU', 'Name', 'Last Synced', 'Status' ] );
}

// Register the list command on the same parent
WP_CLI::add_command( 'myplugin list', [ new MyPlugin_CLI(), 'list_products' ] );

NOTE: Use WP_CLI::confirm() before destructive operations, WP_CLI::error() to exit with a non-zero code (which fails CI pipelines), and WP_CLI::debug() for verbose output that only appears with the --debug flag. Document every command with @subcommand and ## OPTIONS docblocks — WP-CLI uses them for wp help myplugin.

Leave Comment

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