DEV Community

Cover image for WP-CLI: Advanced Techniques for Real-World WordPress Development — Part 2
Kushang Tailor
Kushang Tailor

Posted on

WP-CLI: Advanced Techniques for Real-World WordPress Development — Part 2

In Part 1, we got WP-CLI installed and covered the essential commands. Now it's time to go deeper — custom commands, automation pipelines, remote management, and safe deployment patterns.


📌 Table of Contents

  1. Advanced Custom PHP Commands
  2. WP-CLI in CI/CD Pipelines
  3. Managing Remote WordPress Sites
  4. The --dry-run Safety Net
  5. Final Thoughts

Advanced Custom PHP Commands

What Makes a WP-CLI Command "Advanced"?

In Part 1, we wrote a simple shell script that chained built-in commands. That's great for setup tasks — but what if you need logic that WP-CLI doesn't have out of the box?

That's where WP_CLI::add_command() comes in. It lets you register your own commands in PHP, with full access to the WordPress codebase, custom arguments, flags, progress bars, formatted output, and error handling.

The anatomy of a custom command looks like this:

WP_CLI::add_command( 'namespace action', callable, $args );
Enter fullscreen mode Exit fullscreen mode
  • namespace — your command group (e.g. cleanup, mysite, tools)
  • action — what it does (e.g. posts, users, cache)
  • callable — a function or class method that runs the logic
  • $args — optional: description, synopsis, usage examples

💡 Always wrap custom commands in if ( defined( 'WP_CLI' ) && WP_CLI ) — this ensures the code only runs in a CLI context and never on a web request.


Example 1 — Simple: Bulk Delete Posts by Status

A clean, practical command to delete posts by any status (draft, pending, trash). Useful after a content migration or cleanup sprint.

<?php
// Usage: wp cleanup posts --status=draft
// File: mu-plugins/cli-cleanup.php

if ( defined( 'WP_CLI' ) && WP_CLI ) {

    WP_CLI::add_command( 'cleanup posts', function( $args, $assoc ) {

        $status = $assoc['status'] ?? 'draft';
        $allowed = [ 'draft', 'pending', 'trash', 'private' ];

        if ( ! in_array( $status, $allowed ) ) {
            WP_CLI::error( "Invalid status. Allowed: " . implode( ', ', $allowed ) );
        }

        $posts = get_posts([
            'post_status' => $status,
            'numberposts' => -1,
            'fields'      => 'ids',
        ]);

        if ( empty( $posts ) ) {
            WP_CLI::warning( "No posts found with status: {$status}" );
            return;
        }

        foreach ( $posts as $id ) {
            wp_delete_post( $id, true );
            WP_CLI::line( "Deleted post #$id" );
        }

        WP_CLI::success( count( $posts ) . " posts permanently deleted." );
    });

}
Enter fullscreen mode Exit fullscreen mode

Run it:

wp cleanup posts --status=draft
wp cleanup posts --status=trash
Enter fullscreen mode Exit fullscreen mode

What it handles:

  • Validates the status before running
  • Warns if nothing is found instead of silently exiting
  • Gives per-item feedback + a final success count

Example 2 — Advanced: User Audit & Export Command

A more powerful command that audits all users, filters by role and last login, exports a CSV report, and optionally deactivates inactive accounts — with --dry-run support baked in.

<?php
// Usage: wp audit users --role=subscriber --days=90 --export --dry-run
// File: mu-plugins/cli-audit.php

if ( defined( 'WP_CLI' ) && WP_CLI ) {

    WP_CLI::add_command( 'audit users', function( $args, $assoc ) {

        $role    = $assoc['role']   ?? 'subscriber';
        $days    = (int) ( $assoc['days']   ?? 90 );
        $dry_run = isset( $assoc['dry-run'] );
        $export  = isset( $assoc['export'] );
        $cutoff  = strtotime( "-{$days} days" );

        WP_CLI::log( "Auditing '{$role}' users inactive for {$days}+ days..." );
        if ( $dry_run ) WP_CLI::log( "[DRY RUN] No changes will be made." );

        $users = get_users([ 'role' => $role, 'number' => -1 ]);

        if ( empty( $users ) ) {
            WP_CLI::warning( "No users found with role: {$role}" );
            return;
        }

        $flagged = [];
        $progress = \WP_CLI\Utils\make_progress_bar(
            'Scanning users', count( $users )
        );

        foreach ( $users as $user ) {
            $last_login = (int) get_user_meta( $user->ID, 'last_login', true );

            // Flag if never logged in OR inactive beyond cutoff
            if ( ! $last_login || $last_login < $cutoff ) {
                $flagged[] = [
                    'ID'         => $user->ID,
                    'login'      => $user->user_login,
                    'email'      => $user->user_email,
                    'registered' => $user->user_registered,
                    'last_login' => $last_login
                        ? date( 'Y-m-d', $last_login )
                        : 'Never',
                ];

                if ( ! $dry_run ) {
                    // Deactivate: remove role instead of deleting
                    $user_obj = new WP_User( $user->ID );
                    $user_obj->remove_role( $role );
                    $user_obj->add_role( 'inactive' );
                }
            }
            $progress->tick();
        }

        $progress->finish();

        if ( empty( $flagged ) ) {
            WP_CLI::success( "All users are active. Nothing to flag." );
            return;
        }

        // Display results as a table
        WP_CLI\Utils\format_items( 'table', $flagged,
            [ 'ID', 'login', 'email', 'registered', 'last_login' ]
        );

        // Export to CSV if requested
        if ( $export ) {
            $file = 'inactive-users-' . date('Y-m-d') . '.csv';
            $fp   = fopen( $file, 'w' );
            fputcsv( $fp, array_keys( $flagged[0] ) );
            foreach ( $flagged as $row ) fputcsv( $fp, $row );
            fclose( $fp );
            WP_CLI::success( "Exported to {$file}" );
        }

        $action = $dry_run ? "would be deactivated" : "deactivated";
        WP_CLI::success( count( $flagged ) . " users {$action}." );
    });

}
Enter fullscreen mode Exit fullscreen mode

Run it:

# Preview only — safe, no changes
wp audit users --role=subscriber --days=90 --dry-run

# Run it for real + export a CSV report
wp audit users --role=subscriber --days=90 --export

# Target a different role and timeframe
wp audit users --role=editor --days=180
Enter fullscreen mode Exit fullscreen mode

What makes this advanced:

  • Progress bar for large user sets
  • --dry-run flag built-in — preview before committing
  • Formatted table output using WP-CLI's own utils
  • CSV export with a timestamped filename
  • Degrades gracefully: warns, doesn't crash

WP-CLI in CI/CD Pipelines

The Basic Idea

CI/CD pipelines (GitHub Actions, GitLab CI, Bitbucket Pipelines, etc.) automate what happens after you push code. WP-CLI fits naturally into the deploy step — after files are transferred, you run WP-CLI commands to bring the environment in sync.

Common Pipeline Tasks with WP-CLI

Task Command
Run DB migrations wp core update-db
Flush rewrite rules wp rewrite flush
Clear object cache wp cache flush
Activate/deactivate plugins wp plugin activate <slug>
Sync options across environments wp option update <key> <value>
Verify WordPress is healthy wp core verify-checksums

Example — GitHub Actions Deploy Step

# .github/workflows/deploy.yml

- name: Deploy to Staging
  run: |
    ssh user@staging.example.com << 'EOF'
      cd /var/www/html

      # Pull latest code
      git pull origin main

      # Run WP-CLI post-deploy tasks
      wp core update-db
      wp rewrite flush
      wp cache flush
      wp plugin activate my-custom-plugin

      echo "✅ Deploy complete"
    EOF
Enter fullscreen mode Exit fullscreen mode

Use Case: Environment Sync on Deploy

When deploying from local → staging → production, option values often differ (API keys, URLs, feature flags). Instead of manually editing the DB:

# Set the correct API base URL for this environment
wp option update api_base_url 'https://staging.example.com/api'

# Enable maintenance mode plugin only on production
wp plugin activate wp-maintenance-mode --url=production.example.com
Enter fullscreen mode Exit fullscreen mode

💡 Keep it simple in pipelines. Only run idempotent commands — things that are safe to re-run if the pipeline retries. flush, update-db, and option update are all safe. Avoid commands that delete data.


Managing Remote WordPress Sites

WP-CLI isn't limited to the machine you're sitting at. With SSH aliases, you can run commands against any remote WordPress site as if you were logged into the server.

Setting Up SSH Aliases

Add this to your wp-cli.yml file in your project root (or ~/.wp-cli/config.yml for global use):

# wp-cli.yml
@staging:
  ssh: user@staging.example.com/var/www/html

@production:
  ssh: user@production.example.com/var/www/html
Enter fullscreen mode Exit fullscreen mode

Now prefix any command with the alias:

# Run a command on staging
wp @staging plugin list

# Flush cache on production
wp @production cache flush

# Search-replace on staging DB
wp @staging search-replace 'http://' 'https://' --all-tables
Enter fullscreen mode Exit fullscreen mode

Managing Multiple Sites at Once

# Update all plugins on both environments
wp @staging plugin update --all
wp @production plugin update --all

# Check core version across all environments
wp @staging core version
wp @production core version
Enter fullscreen mode Exit fullscreen mode

⚠️ Always run on staging first. Confirm the output is what you expect — then run on production.

Useful Flags for Remote Work

Flag Purpose
--ssh=user@host/path One-off remote command without an alias
--skip-plugins Run without loading plugins (useful when a plugin is broken)
--skip-themes Same as above for themes
--quiet Suppress output — good for cron jobs
--url=<domain> Target a specific site in a multisite network

The --dry-run Safety Net

What Is --dry-run?

--dry-run is a flag supported by several WP-CLI commands that lets you preview exactly what will happen — without actually doing it.

Think of it as reading the recipe out loud before you start cooking. You catch mistakes before they cost you.

# See what would be replaced — nothing changes in the DB
wp search-replace 'http://oldsite.com' 'https://newsite.com' --all-tables --dry-run
Enter fullscreen mode Exit fullscreen mode

The output shows every table and row that would be affected, with a count — but the database is untouched.


Why --dry-run Is Your Safest Habit

When you're working with WP-CLI, there is no undo button. A wrong search-replace or an accidental delete is permanent unless you have a backup. --dry-run gives you a confirmation step before anything irreversible happens.

Here's why it matters in practice:

  • Catches scope creep — you might expect 10 rows to change, but dry-run shows 1,400. That's a signal to narrow your query.
  • Validates your syntax — a typo in your command fails safely instead of corrupting data.
  • Builds confidence — especially useful when running commands on production for the first time.
  • Documents what will happen — share the dry-run output with a teammate for a quick review before committing.

🔒 Best practice: Make --dry-run the first run, always. Only move to the real command after the preview looks exactly right.


Real-World Use Cases

Use Case 1 — Safe Site Migration

Before doing a full URL swap after moving a site:

# Step 1: Preview — how many rows will change?
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables \
  --dry-run

# Output shows: 47 replacements across 6 tables
# Looks right — now run for real:

# Step 2: Commit
wp search-replace 'https://old-domain.com' 'https://new-domain.com' \
  --all-tables
Enter fullscreen mode Exit fullscreen mode

Use Case 2 — Bulk Post Deletion Preview

Before deleting old draft posts:

# Step 1: Dry run — list what would be deleted
wp post list --post_status=draft --format=count

# Shows: 83 posts

# Step 2: Delete only if count looks right
wp post delete $(wp post list --post_status=draft --format=ids) \
  --force
Enter fullscreen mode Exit fullscreen mode

Use Case 3 — Plugin Update Safety Check

Before mass-updating on production:

# Check what has updates available (non-destructive by nature)
wp plugin update --all --dry-run

# Review the list — then update for real
wp plugin update --all
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

WP-CLI's true value isn't any single command — it's the compounding effect of everything working together. Custom commands give you project-specific tools. CI/CD integration makes deployments repeatable. SSH aliases let you manage any site from one terminal. And --dry-run means you can move fast without breaking things.

If Part 1 was about getting started, Part 2 is about building trust in your own workflow. The developers who get the most out of WP-CLI are the ones who start scripting their own patterns — and this is exactly how you do it.


🔗 Missed Part 1? Start here → — installation, must-know commands, and your first shell script.


Found this useful? Drop a ❤️ and share it with someone who's still clicking through wp-admin.

Top comments (0)