Automating WordPress with WP-CLI: A Complete Guide
WP-CLI is the command-line interface for WordPress, and it is the single most underused tool in the WordPress ecosystem. Developers who interact with WordPress exclusively through the browser admin are leaving enormous efficiency gains on the table. Every WordPress operation that requires clicking through admin screens, installing a plugin, updating a setting, creating a post, managing a user, running a database query, can be done faster, more reliably, and more repeatably through WP-CLI.
At Harbor Software, WP-CLI is the backbone of our WordPress automation. We use it for everything from initial site setup to ongoing maintenance, content migrations, deployment pipelines, and even programmatic Elementor page management. This guide covers the commands, patterns, and scripts that form the core of our WordPress automation toolkit, built from five years of production use across dozens of client sites ranging from simple blogs to complex WooCommerce stores with thousands of products.
Installation and Environment Setup
WP-CLI installation varies by environment. On most Linux servers and managed WordPress hosts (Cloudways, Kinsta, WP Engine, SiteGround), WP-CLI comes pre-installed and accessible via the wp command. On local development environments, you need to set it up explicitly, and the setup varies significantly between operating systems and local server solutions.
# Linux/macOS installation
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
# Verify installation
wp --info
# Output should include:
# WP-CLI version: 2.9.0
# PHP binary: /usr/bin/php
# WordPress path: /var/www/html
For local development with LocalWP (which we use extensively for WordPress development on Windows), WP-CLI is bundled but not in the system PATH. You need to invoke it through the bundled PHP binary with the correct configuration file:
# LocalWP pattern (Windows) - the full invocation looks like this:
"C:/Users/user/AppData/Roaming/Local/lightning-services/php-8.2.27+1/bin/win64/php.exe"
-c "C:/Users/user/AppData/Roaming/Local/run/SITE_ID/conf/php/php.ini"
"path/to/wp-cli.phar" <command>
# We alias this in our project scripts for convenience
alias wp='"C:/path/to/php.exe" -c "C:/path/to/conf/php.ini" "../../../wp-cli.phar"'
The -c flag pointing to the correct php.ini is critical and is the source of the most common WP-CLI setup failures. Without it, PHP will use its default configuration, which typically lacks the MySQL socket configuration needed to connect to the LocalWP database. This is the number one cause of “Error establishing a database connection” messages when running WP-CLI locally. If you see this error, the fix is almost always the php.ini path, not the database credentials.
Site Setup Automation
A fresh WordPress installation requires dozens of admin clicks to configure properly: setting the timezone, permalink structure, disabling default comments, configuring the reading settings, installing and activating the correct plugins, creating the essential pages, setting up the navigation menus, and removing the default Hello World post and sample page. WP-CLI reduces this entire process to a single script that runs in under 60 seconds.
#!/bin/bash
# setup-wordpress.sh - Complete WordPress site configuration
# Usage: cd /path/to/wordpress && bash setup-wordpress.sh
set -e # Exit immediately on any error
SITE_URL="https://example.com"
SITE_TITLE="My Business"
ADMIN_EMAIL="admin@example.com"
echo "Configuring core settings..."
wp option update blogdescription "Professional services for modern businesses"
wp option update timezone_string "Asia/Karachi"
wp option update date_format "F j, Y"
wp option update time_format "g:i a"
wp option update start_of_week 1
wp option update permalink_structure '/%postname%/'
wp rewrite flush
# Disable comments site-wide (most business sites do not need them)
wp option update default_comment_status closed
wp option update default_ping_status closed
# Reading settings - static front page with dedicated blog page
wp option update posts_per_page 12
wp option update show_on_front page
# Create essential pages that every business site needs
echo "Creating pages..."
wp post create --post_type=page --post_title='Home' --post_status=publish
wp post create --post_type=page --post_title='About' --post_status=publish
wp post create --post_type=page --post_title='Services' --post_status=publish
wp post create --post_type=page --post_title='Blog' --post_status=publish
wp post create --post_type=page --post_title='Contact' --post_status=publish
# Set homepage and blog page using the IDs of the pages we just created
HOME_ID=$(wp post list --post_type=page --title='Home' --field=ID)
BLOG_ID=$(wp post list --post_type=page --title='Blog' --field=ID)
wp option update page_on_front $HOME_ID
wp option update page_for_posts $BLOG_ID
# Install and activate our standard plugin stack
echo "Installing plugins..."
wp plugin install elementor --activate
wp plugin install wordpress-seo --activate
wp plugin install litespeed-cache --activate
wp plugin install wordfence --activate
wp plugin install updraftplus --activate
wp plugin install wp-mail-smtp --activate
# Remove default content that ships with every WordPress install
wp post delete 1 --force # "Hello World" post
wp post delete 2 --force # "Sample Page"
wp plugin delete hello akismet # Unused default plugins
wp theme delete twentytwentytwo twentytwentythree # Old default themes
# Create and populate the main navigation menu
echo "Setting up navigation..."
wp menu create "Main Menu"
wp menu item add-post main-menu $HOME_ID
wp menu item add-post main-menu $(wp post list --post_type=page --title='About' --field=ID)
wp menu item add-post main-menu $(wp post list --post_type=page --title='Services' --field=ID)
wp menu item add-post main-menu $BLOG_ID
wp menu item add-post main-menu $(wp post list --post_type=page --title='Contact' --field=ID)
wp menu location assign main-menu primary
echo "Setup complete. Site configured at $SITE_URL"
This script replaces 30-45 minutes of manual admin work with a 60-second automated process. More importantly, it is repeatable and consistent. Every site we set up gets the same baseline configuration. There are no “forgot to disable comments” or “forgot to change the permalink structure” mistakes because the script handles everything deterministically. We have run variants of this script over 100 times across client projects, and it has never produced an incorrectly configured site.
We maintain environment-specific versions of this script. The staging version disables search engine indexing (wp option update blog_public 0) and installs debugging plugins. The production version enables indexing and installs monitoring plugins. The local development version installs Query Monitor and Debug Bar for development workflows. Same base script, different environment overlays applied conditionally based on a WP_ENVIRONMENT_TYPE variable.
Content Management at Scale
WP-CLI’s content management commands transform how you handle bulk operations that would be impractical or impossible through the admin interface. Consider a common migration scenario: a client is migrating from an old site and has 500 blog posts that need their author changed, categories reassigned, and featured images set from a mapping file.
# Bulk update post author - reassign all posts from user ID 2 to user ID 5
wp post list --post_type=post --author=2 --field=ID | xargs -I {} wp post update {} --post_author=5
# Reassign categories: move all posts from 'News' category to 'Blog' category
wp post list --post_type=post --category=news --field=ID | while read id; do
wp post term remove $id category news
wp post term add $id category blog
echo "Moved post $id from News to Blog"
done
# Bulk set featured images from a CSV mapping file
# CSV format: post_id,image_url (one per line)
while IFS=, read -r post_id image_url; do
attachment_id=$(wp media import "$image_url" --post_id=$post_id --porcelain)
wp post meta update $post_id _thumbnail_id $attachment_id
echo "Set featured image for post $post_id: attachment $attachment_id"
done < featured-images.csv
The --porcelain flag is essential for scripting and is one of the most useful WP-CLI features for automation. It strips all human-readable output except the bare value (usually an ID), making it pipeable to the next command. Without it, wp media import outputs a full success message with decorative text that would break the post meta update command when used in a pipeline.
For WooCommerce operations, WP-CLI integrates with the WooCommerce REST API through the wp wc subcommand, giving you full programmatic control over products, orders, customers, and settings:
# List all published products in JSON format for processing
wp wc product list --status=publish --format=json
# Create a new product with full details
wp wc product create
--name="Premium Car Wax"
--type=simple
--regular_price="2499"
--sale_price="1999"
--description="Professional-grade car wax with 12-month protection"
--short_description="Premium wax, 12-month protection"
--categories='[{"id": 15}]'
--manage_stock=true
--stock_quantity=150
--status=publish
# Bulk price update: increase all product prices by 10%
wp wc product list --field=id --format=csv | while read id; do
current_price=$(wp wc product get $id --field=regular_price)
new_price=$(echo "$current_price * 1.10" | bc | xargs printf "%.2f")
wp wc product update $id --regular_price="$new_price"
echo "Product $id: $current_price -> $new_price"
done
Database Operations and Maintenance
WordPress databases accumulate cruft over time: post revisions that pile up with every save, expired transient options from plugins that do not clean up after themselves, orphaned postmeta from deleted plugins, spam comments that were marked but not purged, and auto-draft posts from abandoned editing sessions. On a site with active content publishing, this cruft can grow the database by 50-200MB per year. WP-CLI provides targeted cleanup commands that keep the database lean and performant:
#!/bin/bash
# weekly-maintenance.sh - Run via cron every Sunday at 3 AM
# This script runs unattended and handles all routine database maintenance
echo "Starting weekly maintenance at $(date)"
# Delete post revisions older than 30 days
# This preserves recent revisions for undo capability while removing old cruft
REVISION_COUNT=$(wp post list --post_type=revision --before="30 days ago" --field=ID | wc -l)
wp post list --post_type=revision --before="30 days ago" --field=ID | xargs -r wp post delete --force
echo "Deleted $REVISION_COUNT old revisions"
# Clean up auto-drafts (abandoned editing sessions)
wp post list --post_type=any --post_status=auto-draft --field=ID | xargs -r wp post delete --force
# Delete spam and trashed comments
wp comment delete $(wp comment list --status=spam --field=ID --format=csv) --force 2>/dev/null
wp comment delete $(wp comment list --status=trash --field=ID --format=csv) --force 2>/dev/null
# Clean expired transients (plugin temporary data that should have been auto-deleted)
wp transient delete --expired
# Optimize all database tables (reclaims space from deleted rows)
wp db optimize
# Report current database size for monitoring
echo "Current database size:"
wp db size --tables --format=table
# Update WordPress core (minor/security releases only - major releases are manual)
wp core update --minor
# Update all plugins and themes to latest versions
wp plugin update --all
wp theme update --all
# Flush all caches to ensure clean state
wp cache flush
wp rewrite flush
echo "Weekly maintenance complete at $(date)"
We run this script as a weekly cron job on every production site we manage. The database optimization alone typically recovers 10-30% of database size on sites with active content publishing. On one client site with 3 years of accumulated revisions that had never been cleaned, the first run reduced the database from 2.1GB to 340MB, resulting in noticeably faster database queries across the entire site.
Search and Replace Operations
The wp search-replace command is indispensable for site migrations. It handles serialized PHP data correctly, which is something that raw SQL search-and-replace will break catastrophically. WordPress stores many settings as serialized PHP arrays in the options and postmeta tables. These arrays include string length counters, and if you change a URL's length without updating the counter, PHP's unserialize() function fails silently, corrupting the data:
# Migration from staging to production URL
# The --precise flag handles serialized data correctly
wp search-replace 'https://staging.example.com' 'https://example.com' --all-tables --precise
# Always do a dry run first to verify the scope of changes
wp search-replace 'https://staging.example.com' 'https://example.com' --all-tables --precise --dry-run
# Dry run output shows exactly what would change:
# Table Column Replacements
# wp_posts post_content 847
# wp_posts guid 312
# wp_postmeta meta_value 1,203
# wp_options option_value 89
# Total replacements: 2,451
The --dry-run flag is your safety net and should be used before every search-replace operation without exception. It reports how many replacements would be made in each table and column without modifying any data. We review the numbers carefully before executing. An unexpectedly high replacement count might mean your search string is too broad. An unexpectedly low count might mean the string does not match the format stored in the database. Either way, the dry run gives you a chance to catch the problem before it affects production data.
Custom WP-CLI Commands
WP-CLI is extensible through custom commands that you register in a must-use plugin. You can create commands that encapsulate complex, project-specific operations into simple, documented CLI calls. This is enormously powerful for creating a CLI vocabulary that your entire team can use without understanding the implementation details:
<?php
/**
* Custom WP-CLI commands for the Autostore project.
* File: mu-plugins/cli-commands.php
*/
if (!defined('WP_CLI') || !WP_CLI) return;
class Autostore_CLI {
/**
* Sync products from external inventory API.
*
* ## OPTIONS
*
* [--limit=<number>]
* : Maximum number of products to sync. Default: all.
*
* [--dry-run]
* : Preview changes without writing to database.
*
* ## EXAMPLES
*
* wp autostore sync-products --limit=50 --dry-run
* wp autostore sync-products
*/
public function sync_products($args, $assoc_args) {
$limit = isset($assoc_args['limit']) ? intval($assoc_args['limit']) : 0;
$dry_run = isset($assoc_args['dry-run']);
$api_url = get_option('autostore_api_endpoint');
$response = wp_remote_get($api_url);
if (is_wp_error($response)) {
WP_CLI::error('API request failed: ' . $response->get_error_message());
return;
}
$products = json_decode(wp_remote_retrieve_body($response), true);
if ($limit > 0) {
$products = array_slice($products, 0, $limit);
}
$created = 0;
$updated = 0;
$progress = WP_CLIUtilsmake_progress_bar('Syncing products', count($products));
foreach ($products as $product) {
$existing = wc_get_products(['sku' => $product['sku'], 'limit' => 1]);
if (!empty($existing)) {
if ($dry_run) {
WP_CLI::log("[DRY RUN] Would update: {$product['name']}");
} else {
$wc_product = $existing[0];
$wc_product->set_regular_price($product['price']);
$wc_product->set_stock_quantity($product['stock']);
$wc_product->save();
}
$updated++;
} else {
if ($dry_run) {
WP_CLI::log("[DRY RUN] Would create: {$product['name']}");
} else {
$wc_product = new WC_Product_Simple();
$wc_product->set_name($product['name']);
$wc_product->set_sku($product['sku']);
$wc_product->set_regular_price($product['price']);
$wc_product->set_stock_quantity($product['stock']);
$wc_product->set_status('publish');
$wc_product->save();
}
$created++;
}
$progress->tick();
}
$progress->finish();
$mode = $dry_run ? '[DRY RUN] ' : '';
WP_CLI::success("{$mode}Sync complete: {$created} created, {$updated} updated");
}
}
WP_CLI::add_command('autostore', 'Autostore_CLI');
Custom commands become the operational API for your WordPress site. Instead of writing documentation that says "go to WooCommerce, navigate to Settings, click Products, find the Inventory tab, and change the stock threshold field," you write wp autostore set-stock-threshold 10. The command is self-documenting (run wp autostore sync-products --help for full usage), version-controlled in your repository alongside the code it operates on, and executable in CI/CD pipelines without human intervention.
Deployment and CI/CD Integration
WP-CLI transforms WordPress deployment from a manual FTP-and-click process into a scripted, repeatable pipeline. Here is a simplified version of the deployment script we use with GitHub Actions:
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy files via rsync
run: |
rsync -avz --delete
--exclude='.git'
--exclude='wp-config.php'
--exclude='wp-content/uploads'
--exclude='.env'
./ user@server:/var/www/html/
- name: Run post-deploy commands via SSH
run: |
ssh user@server 'cd /var/www/html &&
wp cache flush &&
wp rewrite flush &&
wp elementor flush-css 2>/dev/null;
echo "Deploy complete at $(date)"'
The --exclude flags in rsync are critical safety measures. You never want to overwrite wp-config.php (contains database credentials and salts unique to each environment), wp-content/uploads (contains user-uploaded media that is not in version control), or .env (contains secrets). These are environment-specific and should not be in your deployment artifact. Accidentally overwriting wp-config.php with a local development version will immediately break the production site's database connection.
Advanced Patterns: eval-file for Complex Operations
The wp eval-file command is the most powerful tool in the WP-CLI arsenal for complex operations. It loads the full WordPress environment (all plugins, themes, and functions) before executing your PHP script, giving you access to every WordPress and WooCommerce function, the database layer, hooks, and the complete plugin API:
<?php
// validate-products.php - Run with: wp eval-file validate-products.php
// Checks every published product for common data quality issues
$products = wc_get_products(['limit' => -1, 'status' => 'publish']);
$issues = [];
foreach ($products as $product) {
$id = $product->get_id();
$name = $product->get_name();
if (!$product->get_image_id()) {
$issues[] = "Product #{$id} ({$name}): Missing featured image";
}
if (floatval($product->get_regular_price()) <= 0) {
$issues[] = "Product #{$id} ({$name}): Price is zero or empty";
}
if (empty(trim($product->get_description()))) {
$issues[] = "Product #{$id} ({$name}): Missing description";
}
if (empty($product->get_sku())) {
$issues[] = "Product #{$id} ({$name}): Missing SKU";
}
}
if (empty($issues)) {
WP_CLI::success("All " . count($products) . " products passed validation.");
} else {
WP_CLI::warning(count($issues) . " issues found:");
foreach ($issues as $issue) {
WP_CLI::log(" - " . $issue);
}
}
We use wp eval-file extensively for operations that are too complex for single commands but too specific for plugins. Product validation before launch, Elementor data patching, content migration transformations, and custom reporting all use this pattern. The scripts are version-controlled, peer-reviewed, and tested on staging before production execution. This approach gives us the full power of PHP with the safety and repeatability of scripted automation.
WP-CLI makes WordPress automation systematic and reliable. Every operation becomes a documented, version-controlled, repeatable script. Manual admin clicks become artifacts of the past. The investment in learning WP-CLI pays for itself within the first week of use, and the compounding returns, fewer mistakes, faster deployments, reproducible environments, grow with every project you apply it to.