WP-CLI comes with hundreds of built-in commands for managing WordPress. But what if you need a command specific to your workflow, plugin, or client requirements? Creating custom WP-CLI commands lets you extend the CLI with your own functionality.
- Why Create Custom WP-CLI Commands?
- Prerequisites
- Command Structure Basics
- Your First Custom Commandnd}
- Using the WP_CLI Class
- Parameter Handling with Synopsis
- Output Formatting
- Error Handling
- Complete Example: Site Audit Command
- Packaging Commands
- Testing Custom Commands
- Distribution
- Real-World Examples
- Troubleshooting
- Next Steps
- Conclusion
In this advanced guide, you’ll learn to create production-ready custom WP-CLI commands from scratch. You’ll understand command structure, parameter handling, the WP_CLI API, and how to package your commands for distribution.
By the end, you’ll have the skills to build professional CLI tools that make WordPress management even more powerful.
Why Create Custom WP-CLI Commands?
Use Cases for Custom Commands
Plugin-Specific Operations
- Flush custom caches
- Run migrations
- Import/export plugin data
- Administrative tasks
Agency/Enterprise Workflows
- Client onboarding automation
- Standardized site audits
- Custom deployment tasks
- Compliance reporting
Development Tools
- Generate boilerplate code
- Database seeding
- Testing helpers
- Performance profiling
Benefits Over Manual Scripts
Integration with WP-CLI Ecosystem
- Automatic WordPress context loading
- Access to all WordPress functions
- Works with WP-CLI packages
- Follows CLI conventions
Professional API
WP_CLI::success(),WP_CLI::error(),WP_CLI::warning()- Built-in progress bars
- Table formatting
- Color output
Distribution
- Share via Composer packages
- Install with
wp package install - Version management
Prerequisites
Before creating custom commands, you should have:
- WP-CLI installed – Installation guide
- PHP knowledge – Object-oriented PHP basics
- WordPress familiarity – Understanding of WordPress hooks and functions
- Bash fundamentals – Bash scripting guide
Command Structure Basics
Command Anatomy
WP-CLI commands follow this structure:
wp <command> <subcommand> <arguments> --<flags>
Examples:
wp plugin install woocommerce
# ^^^^^^ ^^^^^^^ ^^^^^^^^^^^
# cmd subcmd argument
wp post list --post_type=page --format=table
# ^^^^ ^^^^ ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
# cmd sub flag=value flag=value
Command Registration
Commands are registered using WP_CLI::add_command():
WP_CLI::add_command('hello', 'My_Hello_Command');
This creates: wp hello
Class-Based vs Function-Based
Class-based (recommended):
class My_Command {
public function greet($args, $assoc_args) {
WP_CLI::success("Hello!");
}
}
WP_CLI::add_command('mycommand greet', 'My_Command');
Function-based:
function my_greet_command($args, $assoc_args) {
WP_CLI::success("Hello!");
}
WP_CLI::add_command('greet', 'my_greet_command');
Best practice: Use classes for better organization and multiple subcommands.
Your First Custom Commandnd}
Let’s create a simple custom command step-by-step.
Step 1: Create Plugin File
Create a plugin to hold your command:
<?php
/**
* Plugin Name: My WP-CLI Commands
* Description: Custom WP-CLI commands
* Version: 1.0.0
*/
// Don't execute in non-CLI context
if (!defined('WP_CLI')) {
return;
}
/**
* Hello World command
*/
class Hello_Command {
/**
* Prints a greeting
*
* ## EXAMPLES
*
* wp hello greet
* wp hello greet --name=John
*/
public function greet($args, $assoc_args) {
$name = isset($assoc_args['name']) ? $assoc_args['name'] : 'World';
WP_CLI::success("Hello, {$name}!");
}
}
// Register the command
WP_CLI::add_command('hello', 'Hello_Command');
Step 2: Install Plugin
Save this as wp-content/plugins/my-cli-commands/my-cli-commands.php
Activate it:
wp plugin activate my-cli-commands
Step 3: Test Your Command
wp hello greet
# Success: Hello, World!
wp hello greet --name=John
# Success: Hello, John!
Congratulations! You’ve created your first custom WP-CLI command.
Step 4: View Help
WP-CLI automatically generates help documentation:
wp help hello greet
Using the WP_CLI Class
The WP_CLI class provides powerful methods for CLI interaction.
Output Methods
// Success message (green)
WP_CLI::success("Operation completed!");
// Error message (red) - exits script
WP_CLI::error("Something went wrong!");
// Warning message (yellow)
WP_CLI::warning("Proceeding with caution...");
// Info message (no color)
WP_CLI::log("Processing item 5 of 10");
// Debug message (only shown with --debug)
WP_CLI::debug("Variable value: " . $value);
Example:
public function process() {
WP_CLI::log("Starting processing...");
if (!file_exists($file)) {
WP_CLI::warning("File not found, using defaults");
}
if ($error) {
WP_CLI::error("Processing failed!"); // Exits here
}
WP_CLI::success("Processing complete!");
}
Progress Bars
For long-running operations:
public function import_posts($args) {
$posts = range(1, 1000);
$progress = \WP_CLI\Utils\make_progress_bar('Importing posts', count($posts));
foreach ($posts as $post) {
// Do import work
sleep(0.01); // Simulate work
$progress->tick();
}
$progress->finish();
WP_CLI::success("Imported " . count($posts) . " posts");
}
Output:
Importing posts 500/1000 [==============> ] 50%
Confirmation Prompts
Confirm destructive operations:
public function delete_all_posts() {
WP_CLI::confirm("Are you sure you want to delete ALL posts?");
// User must type 'y' or 'yes' to continue
// Otherwise, script exits
// Proceed with deletion
WP_CLI::success("All posts deleted");
}
Usage:
wp mycommand delete-all-posts
# Are you sure you want to delete ALL posts? [y/n]
Skip confirmation in scripts:
wp mycommand delete-all-posts --yes
Learn more in the official WP_CLI class documentation.
Parameter Handling with Synopsis
Synopsis defines your command’s parameters and flags.
Basic Synopsis
/**
* Import data from file
*
* ## OPTIONS
*
* <file>
* : Path to import file
*
* [--format=<format>]
* : Import format (json, csv)
* ---
* default: json
* options:
* - json
* - csv
* ---
*
* ## EXAMPLES
*
* wp mycommand import data.json
* wp mycommand import data.csv --format=csv
*
* @when after_wp_load
*/
public function import($args, $assoc_args) {
list($file) = $args; // Required positional argument
$format = $assoc_args['format']; // Has default value
WP_CLI::log("Importing {$file} as {$format}");
}
Parameter Types
Required positional argument:
/**
* <file>
* : Path to file
*/
Usage: wp cmd import file.json
Optional positional argument:
/**
* [<file>]
* : Path to file (optional)
*/
Required flag:
/**
* --user=<id>
* : User ID (required)
*/
Usage: wp cmd process --user=1
Optional flag with default:
/**
* [--format=<format>]
* : Output format
* ---
* default: table
* options:
* - table
* - json
* - csv
* ---
*/
Boolean flag:
/**
* [--dry-run]
* : Run without making changes
*/
Usage: wp cmd process --dry-run
Check in code:
$dry_run = isset($assoc_args['dry-run']) && $assoc_args['dry-run'];
Accessing Parameters
public function command($args, $assoc_args) {
// Positional arguments (ordered)
$first_arg = isset($args[0]) ? $args[0] : '';
$second_arg = isset($args[1]) ? $args[1] : '';
// Or use list()
list($file, $action) = $args;
// Associative arguments (flags)
$format = $assoc_args['format']; // No default, may not exist
$format = isset($assoc_args['format']) ? $assoc_args['format'] : 'table';
// Get with default helper
$format = \WP_CLI\Utils\get_flag_value($assoc_args, 'format', 'table');
}
Output Formatting
Table Format
Display data as tables:
public function list_users() {
$users = get_users(array('number' => 10));
$items = array();
foreach ($users as $user) {
$items[] = array(
'ID' => $user->ID,
'Username' => $user->user_login,
'Email' => $user->user_email,
'Role' => implode(', ', $user->roles)
);
}
\WP_CLI\Utils\format_items('table', $items, array('ID', 'Username', 'Email', 'Role'));
}
Output:
+----+----------+------------------+-------------+
| ID | Username | Email | Role |
+----+----------+------------------+-------------+
| 1 | admin | admin@site.com | administrator |
| 2 | editor | editor@site.com | editor |
+----+----------+------------------+-------------+
JSON Format
\WP_CLI\Utils\format_items('json', $items, array('ID', 'Username', 'Email'));
Output:
[
{"ID":"1","Username":"admin","Email":"admin@site.com"},
{"ID":"2","Username":"editor","Email":"editor@site.com"}
]
CSV Format
\WP_CLI\Utils\format_items('csv', $items, array('ID', 'Username', 'Email'));
Output:
ID,Username,Email
1,admin,admin@site.com
2,editor,editor@site.com
Supporting Multiple Formats
/**
* [--format=<format>]
* : Output format
* ---
* default: table
* options:
* - table
* - json
* - csv
* - count
* ---
*/
public function list($args, $assoc_args) {
$format = \WP_CLI\Utils\get_flag_value($assoc_args, 'format', 'table');
$items = $this->get_items();
if ('count' === $format) {
WP_CLI::log(count($items));
return;
}
\WP_CLI\Utils\format_items($format, $items, array('ID', 'Title', 'Status'));
}
Error Handling
Validation
Always validate parameters:
public function import($args, $assoc_args) {
list($file) = $args;
// Check file exists
if (!file_exists($file)) {
WP_CLI::error("File not found: {$file}");
}
// Check file is readable
if (!is_readable($file)) {
WP_CLI::error("File is not readable: {$file}");
}
// Validate format
$format = $assoc_args['format'];
$allowed = array('json', 'csv');
if (!in_array($format, $allowed)) {
WP_CLI::error("Invalid format: {$format}. Must be: " . implode(', ', $allowed));
}
// Proceed with import...
}
Try-Catch
Handle exceptions gracefully:
public function process() {
try {
$this->do_risky_operation();
WP_CLI::success("Operation completed");
} catch (Exception $e) {
WP_CLI::error("Operation failed: " . $e->getMessage());
}
}
Exit Codes
Commands should return appropriate exit codes:
0– Success1– Error (automatic withWP_CLI::error())
Complete Example: Site Audit Command
Let’s build a real-world command that audits a WordPress site.
<?php
/**
* Plugin Name: Site Audit Command
* Description: Custom WP-CLI command for site auditing
* Version: 1.0.0
*/
if (!defined('WP_CLI')) {
return;
}
/**
* Performs comprehensive site audits
*/
class Site_Audit_Command {
/**
* Run a complete site audit
*
* ## OPTIONS
*
* [--format=<format>]
* : Output format
* ---
* default: table
* options:
* - table
* - json
* ---
*
* [--save=<file>]
* : Save results to file
*
* ## EXAMPLES
*
* wp site-audit run
* wp site-audit run --format=json
* wp site-audit run --save=audit-report.json
*
* @when after_wp_load
*/
public function run($args, $assoc_args) {
WP_CLI::log("Running site audit...");
$results = array();
// WordPress version
$results[] = $this->check_wordpress_version();
// Plugin updates
$results[] = $this->check_plugin_updates();
// Theme updates
$results[] = $this->check_theme_updates();
// Database size
$results[] = $this->check_database_size();
// Upload directory size
$results[] = $this->check_uploads_size();
// Orphaned data
$results[] = $this->check_orphaned_data();
// Get format
$format = \WP_CLI\Utils\get_flag_value($assoc_args, 'format', 'table');
// Output results
if ('json' === $format) {
WP_CLI::log(json_encode($results, JSON_PRETTY_PRINT));
} else {
\WP_CLI\Utils\format_items('table', $results, array('Check', 'Status', 'Details'));
}
// Save to file if requested
if (isset($assoc_args['save'])) {
file_put_contents($assoc_args['save'], json_encode($results, JSON_PRETTY_PRINT));
WP_CLI::success("Audit saved to: " . $assoc_args['save']);
}
WP_CLI::success("Audit complete!");
}
private function check_wordpress_version() {
global $wp_version;
$updates = get_core_updates();
$has_update = isset($updates[0]) && $updates[0]->response === 'upgrade';
return array(
'Check' => 'WordPress Version',
'Status' => $has_update ? 'โ Update Available' : 'โ Up to Date',
'Details' => 'Current: ' . $wp_version
);
}
private function check_plugin_updates() {
$updates = get_plugin_updates();
$count = count($updates);
return array(
'Check' => 'Plugin Updates',
'Status' => $count > 0 ? "โ {$count} available" : 'โ All updated',
'Details' => $count > 0 ? implode(', ', array_keys($updates)) : 'None'
);
}
private function check_theme_updates() {
$updates = get_theme_updates();
$count = count($updates);
return array(
'Check' => 'Theme Updates',
'Status' => $count > 0 ? "โ {$count} available" : 'โ All updated',
'Details' => $count > 0 ? implode(', ', array_keys($updates)) : 'None'
);
}
private function check_database_size() {
global $wpdb;
$size = $wpdb->get_var("
SELECT SUM(data_length + index_length) / 1024 / 1024
FROM information_schema.TABLES
WHERE table_schema = '{$wpdb->dbname}'
");
$size_mb = round($size, 2);
$status = $size_mb > 500 ? 'โ Large' : 'โ Normal';
return array(
'Check' => 'Database Size',
'Status' => $status,
'Details' => "{$size_mb} MB"
);
}
private function check_uploads_size() {
$upload_dir = wp_upload_dir();
$path = $upload_dir['basedir'];
$size = $this->get_directory_size($path);
$size_mb = round($size / 1024 / 1024, 2);
$status = $size_mb > 5000 ? 'โ Large' : 'โ Normal';
return array(
'Check' => 'Uploads Directory',
'Status' => $status,
'Details' => "{$size_mb} MB"
);
}
private function check_orphaned_data() {
global $wpdb;
// Count orphaned post meta
$orphaned = $wpdb->get_var("
SELECT COUNT(*)
FROM {$wpdb->postmeta} pm
LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.ID IS NULL
");
$status = $orphaned > 0 ? "โ {$orphaned} found" : 'โ None';
return array(
'Check' => 'Orphaned Post Meta',
'Status' => $status,
'Details' => $orphaned > 0 ? "Run cleanup recommended" : 'Clean'
);
}
private function get_directory_size($path) {
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)) as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
return $size;
}
}
WP_CLI::add_command('site-audit', 'Site_Audit_Command');
Usage
# Run audit
wp site-audit run
# JSON output
wp site-audit run --format=json
# Save to file
wp site-audit run --save=audit-2025.json
Output:
+---------------------+-------------------+------------------+
| Check | Status | Details |
+---------------------+-------------------+------------------+
| WordPress Version | โ Up to Date | Current: 6.4.2 |
| Plugin Updates | โ 3 available | woocommerce, ... |
| Theme Updates | โ All updated | None |
| Database Size | โ Normal | 45.23 MB |
| Uploads Directory | โ Large | 5432.12 MB |
| Orphaned Post Meta | โ 156 found | Run cleanup... |
+---------------------+-------------------+------------------+
Success: Audit complete!
Packaging Commands
Create Composer Package
Structure your command as a Composer package:
my-wp-cli-command/
โโโ composer.json
โโโ command.php
โโโ README.md
composer.json:
{
"name": "vendor/my-wpcli-command",
"description": "Custom WP-CLI command for site auditing",
"type": "wp-cli-package",
"require": {
"php": ">=7.4"
},
"autoload": {
"files": ["command.php"]
},
"extra": {
"commands": [
"site-audit"
]
}
}
Install via Composer
Users can install your command:
wp package install vendor/my-wpcli-command
Or via composer.json:
{
"require": {
"vendor/my-wpcli-command": "^1.0"
}
}
Learn more: WP-CLI Package Index
Testing Custom Commands
Manual Testing
# Test basic functionality
wp site-audit run
# Test with flags
wp site-audit run --format=json
# Test error handling (invalid flag)
wp site-audit run --format=invalid
Automated Testing with Behat
For serious projects, use WP-CLI’s testing framework:
# Install testing framework
composer require --dev wp-cli/wp-cli-tests
Example test:
Feature: Site audit command
Scenario: Run basic audit
Given a WP install
When I run `wp site-audit run`
Then STDOUT should contain "Audit complete"
And the return code should be 0
Scenario: Save audit to file
Given a WP install
When I run `wp site-audit run --save=test-audit.json`
Then the test-audit.json file should exist
And STDOUT should contain "Audit saved"
Learn more: WP-CLI Testing Documentation
Distribution
GitHub Repository
Host your command on GitHub:
- Create repo:
my-wpcli-command - Push code with
composer.json - Tag releases:
v1.0.0,v1.1.0, etc.
WP-CLI Package Index
Submit to WP-CLI Package Index:
- Follow package guidelines
- Submit pull request to package index
- Wait for review and approval
WordPress Plugin Directory
Optionally distribute as WordPress plugin:
- Works automatically when plugin is activated
- Discoverable via WordPress.org
- Easier for non-technical users
Real-World Examples
Official WP-CLI Packages
Study these for inspiration:
- wp-cli/doctor-command – Health checks
- wp-cli/profile-command – Performance profiling
- wp-cli/scaffold-command – Code generation
Popular Third-Party Commands
- WooCommerce CLI – WooCommerce management
- ACF CLI – Advanced Custom Fields operations
- Yoast SEO CLI – SEO management
Browse more: WP-CLI Package Index
Troubleshooting
Command Not Found
Problem: wp mycommand returns “Error: ‘mycommand’ is not a registered wp command.”
Solutions:
- Check plugin is activated:
wp plugin list
- Verify
WP_CLIis defined:
if (!defined('WP_CLI')) {
return; // Command won't register
}
- Check command registration:
WP_CLI::add_command('mycommand', 'My_Command');
Parameters Not Working
Problem: Flags are ignored or cause errors.
Solutions:
- Check synopsis syntax in docblock
- Verify parameter names match:
$format = $assoc_args['format']; // Must match --format
- Provide defaults for optional parameters:
$format = \WP_CLI\Utils\get_flag_value($assoc_args, 'format', 'table');
Memory Limit Errors
Problem: Command fails with “Allowed memory size exhausted.”
Solution: Process items in batches:
public function process_all() {
$per_page = 100;
$page = 1;
do {
$items = get_posts(array(
'posts_per_page' => $per_page,
'paged' => $page
));
foreach ($items as $item) {
// Process item
}
$page++;
} while (count($items) === $per_page);
}
Next Steps
You now know how to create production-ready custom WP-CLI commands!
Continue Learning
- Bash Functions for WordPress – Combine with shell scripts
- WordPress CI/CD with GitHub Actions – Use commands in pipelines
- WP-CLI Config Files – Advanced configuration
Build These Commands
Practice projects:
- Content migration command
- Security audit command
- Performance optimization command
- Database cleanup command
- Multi-site sync command
Master WordPress Automation
Want to build advanced automation systems with custom commands, APIs, and deployment pipelines?
Join the WPCLI Mastery waitlist and get:
- Advanced command development patterns
- Real-world command examples library
- Package creation templates
- Early bird course pricing ($99 vs $199)
Conclusion
Creating custom WP-CLI commands lets you extend WordPress automation with your specific workflow needs. Whether you’re building internal tools, distributing plugins, or streamlining client management, custom commands are essential for professional WordPress development.
Key takeaways:
- Use classes for organization and multiple subcommands
- Leverage
WP_CLIclass methods for professional output - Define parameters with synopsis for clear documentation
- Support multiple output formats (table, JSON, CSV)
- Package with Composer for easy distribution
- Test thoroughly before releasing
The custom commands you build today become reusable tools that save time forever.
Ready to build? Start with the site audit example and adapt it to your needs.
Questions about custom WP-CLI commands? Drop a comment below!
Found this helpful? Share with other WordPress developers building CLI tools.
Next: Learn how to integrate external APIs with WordPress using WP-CLI commands.
