As a WordPress developer, WP-CLI is one of the tools I use almost daily. Out of the box, it already gives me hundreds of commands to manage posts, users, plugins, and databases. But over the years, I’ve learned that the real magic happens when you start building your own custom commands.
Here’s why:
- Every project has repetitive tasks.
- Some things can’t be done efficiently from wp-admin.
- Automation saves hours (and prevents human error).
That’s where custom WP-CLI commands come in. Instead of manually running complex scripts or clicking through the dashboard, I build commands like:
wp myplugin sync-data
wp myplugin clean-cache --force
wp myplugin generate-reports --month=08 --year=2025
These aren’t just “developer toys”, they’re production-ready tools that make scaling and maintaining WordPress sites much easier.
In this article, I’ll walk you through how I develop custom WP-CLI commands, while also sharing what I’ve learned about performance and scalability (because the last thing you want is a command that crashes a large database).
When and Why I Create Custom WP-CLI Commands?
Over the years, I’ve worked on projects ranging from small business websites to enterprise-level WordPress solutions. One pattern I see everywhere is the need for repeatable, automated processes.
Sure, you can write one-off scripts or even use plugins, but WP-CLI has a unique advantage:
- It’s lightweight, no overhead of UI.
- It’s consistent, works the same across staging, production, and local.
- It’s scriptable, can be tied into cron jobs, deployment workflows, or CI/CD pipelines.
Here are some real scenarios where I decided to create custom WP-CLI commands:
1. Content Imports & Synchronization
A client needed to sync product data from an external API into WordPress every night. Instead of building a bulky admin interface, I wrote:
wp products sync --limit=500
This command let me:
- Run imports manually during testing.
- Add it to a cron job for automation.
- Scale it by controlling batch sizes with
--limit.
2. Cache and Log Maintenance
For high-traffic sites, caches and logs grow quickly. I built commands like:
wp site clean-cache --network
wp logs rotate --days=30
This makes it easier to keep things lean without logging into hosting dashboards.
3. Large-Scale Data Cleanup
A WooCommerce site with 100k+ orders had to bulk-update old meta values. Doing it through wp-admin would time out. Instead:
wp orders clean-meta --field="_old_tracking_id"
This ran safely in batches, completing a job that would’ve been impossible through the browser.
4. DevOps & Deployment
I also write WP-CLI commands for DevOps tasks, like:
- Seeding test data.
- Running migrations.
- Setting environment flags.
It integrates smoothly into GitHub Actions, GitLab CI, or any deployment pipeline.
For me, the decision to build a custom WP-CLI command usually comes when I ask myself:
- “Do I repeat this action often?”
- “Does it need to run reliably without UI overhead?”
- “Could automation make this safer and faster?”
If the answer is yes, it’s a perfect candidate for WP-CLI.
Setting Up a Custom WP-CLI Command (Basic Structure)
When I build custom WP-CLI commands, I usually approach them the same way I approach a plugin feature: clean, modular, and well-documented. WP-CLI makes it straightforward to register new commands using PHP.
Here’s a simple breakdown:
Step 1: Hook into WP-CLI
Custom commands can be registered in a plugin, a theme (not recommended for long-term use), or even in mu-plugins. I usually place them in a plugin, because that’s easier to version-control and maintain.
At its simplest, registering a command looks like this:
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'myplugin hello', 'MyPlugin_Hello_Command' );
}
myplugin hello→ the command name you’ll run in terminal.MyPlugin_Hello_Command→ the PHP class handling it.
Step 2: Create the Command Class
class MyPlugin_Hello_Command {
/**
* Prints a friendly greeting.
*
* ## OPTIONS
*
* [--name=<name>]
* : The name of the person to greet.
*
* ## EXAMPLES
*
* wp myplugin hello --name=Mehul
*
* @when after_wp_load
*/
public function __invoke( $args, $assoc_args ) {
$name = $assoc_args['name'] ?? 'World';
WP_CLI::success( "Hello, {$name}!" );
}
}
Now if I run:
wp myplugin hello --name=Mehul
Output:
Success: Hello, Mehul!
Step 3: Add Help Docs and Examples
One thing I always emphasize is documentation inside the command itself. WP-CLI automatically parses docblocks into command help screens.
For example:
wp help myplugin hello
Will display the description, options, and examples you wrote in the PHP docblock.
This is extremely helpful when your commands are used by teams, agencies, or future-you who may forget the syntax months later.
Pro Tip: I always use a namespace prefix (like myplugin or clientname) so that commands don’t conflict with others.
Adding Arguments, Options, and Output
Once I’ve set up the basic structure of a WP-CLI command, the next step is making it flexible and powerful. That means handling arguments, options, and returning clean output.
This is where custom commands start becoming truly useful.
Positional Arguments ($args)
Positional arguments are the simplest way to pass values. They are ordered, just like parameters in a function.
Example:
class MyPlugin_Greet_Command {
/**
* Greets a person by name.
*
* ## EXAMPLES
*
* wp myplugin greet Mehul
*/
public function __invoke( $args ) {
$name = $args[0] ?? 'World';
WP_CLI::success( "Hello, {$name}!" );
}
}
Run:
wp myplugin greet Mehul
Output:
Success: Hello, Mehul!
Associative Arguments ($assoc_args)
These let you use flags like --key=value. I use them when commands need flexibility or multiple options.
Example:
class MyPlugin_Report_Command {
/**
* Generates a monthly report.
*
* ## OPTIONS
*
* [--month=<month>]
* : The month for the report (e.g., 08).
*
* [--year=<year>]
* : The year for the report (e.g., 2025).
*/
public function __invoke( $args, $assoc_args ) {
$month = $assoc_args['month'] ?? date('m');
$year = $assoc_args['year'] ?? date('Y');
WP_CLI::line( "Generating report for {$month}/{$year}..." );
// Your business logic here
WP_CLI::success( "Report generated!" );
}
}
Run:
wp myplugin report --month=08 --year=2025
Output Formatting
When working with large datasets, output readability matters. WP-CLI provides helpers like WP_CLI\Utils\format_items.
Example:
WP_CLI\Utils\format_items(
'table',
[
[ 'ID' => 1, 'Name' => 'Mehul' ],
[ 'ID' => 2, 'Name' => 'John' ],
],
[ 'ID', 'Name' ]
);
Output:
+----+-------+
| ID | Name |
+----+-------+
| 1 | Mehul |
| 2 | John |
+----+-------+
This makes your commands feel professional and scalable for team usage.
Error Handling
Always return clear messages when things go wrong. WP-CLI has built-in helpers:
if ( empty( $args[0] ) ) {
WP_CLI::error( "You must provide a name." );
}
Output:
Error: You must provide a name.
This prevents silent failures and guides the user.
For me, this is the turning point where custom commands stop being scripts and start becoming tools. Adding arguments, options, and proper output makes them scalable and team-friendly.
Handling Errors and Validations
One thing I’ve learned from building custom WP-CLI commands is that the command itself should protect the user as much as possible. Mistakes happen, and in production environments, one wrong command can mean downtime or lost data.
That’s why I treat validation and error handling as first-class citizens when writing WP-CLI commands.
Using WP_CLI::error()
If something is missing or invalid, stop execution immediately with a clear message.
if ( empty( $assoc_args['month'] ) ) {
WP_CLI::error( "You must provide a --month argument." );
}
Output:
Error: You must provide a --month argument.
I use this all the time for required parameters like IDs or file paths.
Using WP_CLI::warning()
Sometimes, the issue isn’t critical but should still be flagged.
if ( $count > 1000 ) {
WP_CLI::warning( "This operation will process over 1000 records. Consider adding a --limit flag." );
}
Output:
Warning: This operation will process over 1000 records. Consider adding a --limit flag.
This helps prevent users from accidentally running heavy operations.
Using WP_CLI::confirm()
For destructive actions, I like to ask for confirmation unless --yes is passed.
WP_CLI::confirm( "Are you sure you want to delete all logs?", $assoc_args );
If the user doesn’t include --yes, it prompts:
Are you sure you want to delete all logs? [y/n]
This little guardrail has saved me (and my clients) from accidental data loss.
Validating Input
I never assume user input is safe. For example:
$year = intval( $assoc_args['year'] ?? 0 );
if ( $year < 2000 || $year > date('Y') ) {
WP_CLI::error( "Invalid year provided." );
}
That way, I catch mistakes before they cause failures or bad data.
Graceful Exit Codes
WP-CLI uses Unix-style exit codes:
0= success1= error
By default, WP_CLI::error() exits with code 1. This is important when your commands run in CI/CD pipelines or cron jobs, because scripts can act differently based on exit codes.
In short: never trust user input. Add confirmations, validate aggressively, and fail fast with clear messages. It’s the difference between a hacky script and a production-ready tool.
Performance Considerations
When I first started writing custom WP-CLI commands, I made the rookie mistake of trying to load everything into memory. On small sites, it worked fine. But on enterprise sites with 100k+ posts or WooCommerce orders, the command would choke, crash, or even take down the server.
That’s when I learned: performance isn’t optional in WP-CLI commands. These commands often run on production servers, and they must be efficient.
Here’s what I keep in mind:
1. Never Load Everything at Once
Instead of fetching all posts at once, I always batch process.
Bad approach:
$posts = get_posts([ 'numberposts' => -1 ]);
foreach ( $posts as $post ) {
// process
}
This loads the entire dataset into memory. On big sites, that’s a disaster.
Better approach:
$offset = 0;
$limit = 500;
do {
$posts = get_posts([
'numberposts' => $limit,
'offset' => $offset,
'post_type' => 'post',
]);
foreach ( $posts as $post ) {
// process each post
}
$offset += $limit;
} while ( count( $posts ) > 0 );
This way, memory stays constant, no matter how large the dataset.
2. Use Direct Database Queries When Needed
Sometimes, WordPress functions add too much overhead. For example, if I only need post IDs, I’ll query directly:
global $wpdb;
$results = $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' LIMIT 500" );
This avoids loading full post objects when they aren’t necessary.
3. Provide --limit and --offset Options
For commands that process large datasets, I always give the user control:
wp myplugin clean-orders --limit=1000 --offset=2000
This lets them run commands in smaller chunks, especially useful on shared hosting.
4. Be Mindful of External API Calls
If your command fetches from an API, add:
- Caching (so it doesn’t re-fetch data unnecessarily).
- Rate limiting (to avoid hitting API quotas).
- Retries with exponential backoff (in case of failures).
5. Log Progress
Long-running commands should provide progress bars or logs:
$progress = \WP_CLI\Utils\make_progress_bar( 'Processing posts', $total );
foreach ( $posts as $post ) {
// do work
$progress->tick();
}
$progress->finish();
This helps me (and my clients) see that something is happening, especially on tasks that take several minutes.
6. Memory & Timeout Awareness
WP-CLI runs via PHP, so it’s bound by memory_limit and max_execution_time.
- Always test on large datasets.
- Consider breaking jobs into multiple commands or queueing them via Action Scheduler for extremely large tasks.
For me, performance is not just about speed. It’s about predictability, knowing the command will run safely on both small blogs and enterprise sites without crashing.
Scalability and Running on Large Sites
When I build WP-CLI commands, I don’t just think about whether they’ll work on my local dev site. I think about whether they’ll survive on enterprise-level WordPress installs.
Scalability is about ensuring your command runs just as smoothly on a blog with 200 posts as it does on a WooCommerce store with 500,000 orders. Over time, I’ve learned a few strategies to make commands production-ready for large sites.
1. Design for Multisite Networks
Many agencies (including my clients) run multisite setups. A command that works on a single site might completely fail across a network unless it’s network-aware.
Example:
foreach ( get_sites() as $site ) {
switch_to_blog( $site->blog_id );
// run command logic
restore_current_blog();
}
By looping through sites with switch_to_blog(), I make sure the command works for all subsites without manual intervention.
2. Provide Batch and Resume Options
On huge datasets, I rarely try to finish everything in one go. Instead, I design commands that can be run incrementally.
Example:
wp orders clean-meta --limit=500 --offset=2000
This way, if the process times out or needs to be paused, I can resume where it left off.
I sometimes add a --resume flag that stores progress in a transient or log file.
3. Parallel Execution (When Safe)
For read-heavy tasks, I sometimes split work into parallel processes using system tools like xargs or background jobs.
Example:
seq 0 9 | xargs -n1 -P4 wp myplugin process-orders --batch
Here, the work is divided into 10 batches, with 4 running in parallel.
I only do this on commands that don’t risk collisions (e.g., read operations or non-overlapping write tasks).
4. Offload Heavy Work to Queues
For operations that are too heavy for direct execution, I offload them into Action Scheduler (the job queue system WooCommerce uses).
My WP-CLI command becomes a job creator instead of a job executor:
as_enqueue_async_action( 'myplugin_process_order', [ 'order_id' => $order_id ] );
This way, tasks are spread out over time and won’t overwhelm the server.
5. Integrate with Deployment Workflows
In larger teams, WP-CLI commands often run as part of CI/CD pipelines. I’ve built commands that:
- Run DB migrations safely on deploy.
- Seed test data in staging.
- Clear caches after deployments.
For example, in GitHub Actions:
- name: Run migrations
run: wp myplugin migrate --yes
This ensures consistency across environments without manual steps.
6. Add Logging and Monitoring
On small sites, echoing messages is fine. On large sites, I log outputs:
WP_CLI::log( "Processed order {$order_id}" );
Or even write logs to a file:
file_put_contents( WP_CONTENT_DIR . '/logs/myplugin.log', "Processed order {$order_id}\n", FILE_APPEND );
This helps me debug if something fails mid-way.
For me, scalability is about future-proofing. Even if a client’s site only has 10k records today, I build commands as if they’ll have 1M tomorrow. It’s one of the things that separates quick scripts from enterprise-grade developer tools.
Real-World Example from My Workflow
Theory is great, but I’ve found that developers really “get it” when they see a real-world command in action. So let me walk you through one of the custom WP-CLI commands I built for a client project.
The Problem
A WooCommerce client had hundreds of thousands of orders. Many of these orders had leftover metadata from an older shipping plugin that was no longer in use. This unnecessary data was:
- Slowing down queries.
- Wasting database storage.
- Making migrations heavier than needed.
Manually cleaning this up was not an option. Even running DELETE queries directly was risky.
The Solution: A Custom Cleanup Command
I wrote a WP-CLI command that safely cleaned up the old order meta in batches, with logging and validation.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'orders clean-meta', 'Orders_Clean_Meta_Command' );
}
class Orders_Clean_Meta_Command {
/**
* Cleans old shipping metadata from WooCommerce orders.
*
* ## OPTIONS
*
* [--limit=<number>]
* : Number of orders to process in one run.
*
* [--offset=<number>]
* : Offset to resume from.
*
* ## EXAMPLES
*
* wp orders clean-meta --limit=500 --offset=0
*/
public function __invoke( $args, $assoc_args ) {
global $wpdb;
$limit = intval( $assoc_args['limit'] ?? 500 );
$offset = intval( $assoc_args['offset'] ?? 0 );
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'shop_order' LIMIT %d OFFSET %d",
$limit,
$offset
)
);
if ( empty( $order_ids ) ) {
WP_CLI::success( "No more orders to process." );
return;
}
foreach ( $order_ids as $order_id ) {
delete_post_meta( $order_id, '_old_shipping_data' );
WP_CLI::log( "Cleaned order {$order_id}" );
}
WP_CLI::success( "Processed " . count( $order_ids ) . " orders." );
}
}
How It Works
- Batching → Processes orders in chunks of 500 (configurable via
--limit). - Resumable → Supports
--offsetso I can run multiple passes safely. - Safe Deletion → Only deletes a specific meta key (
_old_shipping_data). - Logging → Outputs each processed order to terminal (and I sometimes extend this to write logs to file).
Running the Command
wp orders clean-meta --limit=500 --offset=1000
- Processes 500 orders starting from the 1000th.
- Perfect for big sites, I can run it overnight in small batches.
The Impact
- Reduced database size by several GB.
- Improved query performance for WooCommerce reports.
- Made backups and migrations significantly faster.
The client didn’t just get a one-time cleanup. They got a reusable tool they can run whenever needed.
This is why I love building custom WP-CLI commands. They solve real pain points, scale well, and can be shared with entire dev teams.
Best Practices for Developers
After building custom WP-CLI commands for years, from quick helpers to enterprise-level tools, I’ve developed a checklist of best practices. These principles make sure my commands are safe, fast, and scalable.
Here’s what I recommend to every developer:
1. Always Validate Inputs
Never trust user input. Whether it’s an ID, date, or file path, validate it.
if ( ! is_numeric( $assoc_args['limit'] ) ) {
WP_CLI::error( "The --limit value must be numeric." );
}
This prevents accidental misuse.
2. Batch Process Everything
Avoid “load everything at once.” For large sites, that will crash. Instead, paginate or limit queries.
--limit=500 --offset=0
Batching makes commands predictable and safe on high-traffic sites.
3. Add --yes for Destructive Actions
For commands that delete or reset data, require confirmation.
WP_CLI::confirm( "Are you sure you want to reset the database?", $assoc_args );
This is a lifesaver in production.
4. Provide Useful Output
Use progress bars, tables, or logs. A developer running the command should know what’s happening.
$progress = \WP_CLI\Utils\make_progress_bar( 'Processing orders', $total );
5. Keep Commands Atomic
One command = one responsibility. Don’t overload commands with multiple unrelated tasks. Instead, break them up:
wp myplugin sync-datawp myplugin clean-cachewp myplugin generate-report
This makes commands easier to test and maintain.
6. Design for Scalability
Write as if the site has 1 million records even if today it only has 1,000. This mindset avoids rewriting later.
7. Namespacing is Non-Negotiable
Prefix your commands with your plugin or project name (wp myplugin …). This prevents conflicts with other plugins or core.
8. Document Commands with Examples
Use docblocks generously so that wp help is always informative.
/**
* Cleans old logs.
*
* ## OPTIONS
* [--days=<number>]
* : Number of days to keep.
*
* ## EXAMPLES
* wp myplugin clean-logs --days=30
*/
9. Build with Automation in Mind
Assume your command will be used in scripts, cron jobs, or CI/CD pipelines.
- Return proper exit codes.
- Don’t rely only on interactive input.
- Be idempotent (running the same command twice shouldn’t break things).
10. Test on Staging Before Production
I’ve learned this the hard way: always run your commands on staging databases first. Even well-tested commands can behave differently on large, messy datasets.
ollowing these best practices turns a quick WP-CLI hack into a production-ready developer tool. And in client projects, that extra polish makes a huge difference.
Conclusion: Why Custom WP-CLI Commands Are Worth It
For me, building custom WP-CLI commands has completely changed the way I work with WordPress. What started as a way to avoid repetitive clicks in wp-admin has become a cornerstone of how I handle automation, scalability, and performance for client projects.
- On small sites, custom commands save time and reduce human error.
- On large or enterprise sites, they’re the only way to safely process millions of records, run batch jobs, and integrate WordPress into modern DevOps workflows.
- And when combined with best practices like batching, validation, and logging, they become production-grade tools, not just quick developer scripts.
Every time I ship a custom WP-CLI command, I feel like I’m not just solving a problem for today. I’m building something that future developers, agencies, and even automation systems can rely on.
What’s Next?
If you’re a developer, I encourage you to:
- Start small with a “hello world” command.
- Add arguments, batching, and logging.
- Think about how your commands can scale to 100k+ records or multisite networks.
And if you’re a business or agency that wants:
- Reliable custom WP-CLI tools for automation,
- Scalable workflows for WooCommerce, multisite, or content-heavy sites,
- Or someone to build enterprise-grade WordPress solutions,
Check out my WordPress Development Services or contact me directly. I’d be happy to discuss how custom WP-CLI commands can save you time, reduce risk, and prepare your site for growth.





