Writing custom WP-CLI commands

Overview #

Occasionally, you may find you need to access or transform data on your site. If it’s more than a dozen posts affected, it’s often more efficient to write what we call a custom WP-CLI command (sometimes called a “bin script”). In writing a custom WP-CLI command, you can easily change strings, assign categories, or add post meta across hundreds or thousands of posts. However, with great power comes great responsibility — any small mistake you make with your logic could have negative repercussions across your entire dataset.

Here are some guidelines we’d encourage you to follow when writing a custom WP-CLI command.

↑ Top ↑

Writing commands #

Check out the great documentation on how to write a command. When you write commands for VIP, there are a few things to keep in mind:

  • You should extend the WPCOM_VIP_CLI_Command class provided in the development helpers, which includes helper functions like stop_the_insanity(). Do this instead of extending WP_CLI_Command.
  • Make sure you require the file that contains your new command in your functions.php file
  • Make sure you only include the command if WP_CLI is defined and true

Here’s an example of what might be in your functions file:

// CLI scripts
if ( defined( 'WP_CLI' ) && WP_CLI ) {
	require_once MY_THEME_DIR . '/inc/class-mycommand1-cli.php';
	require_once MY_THEME_DIR . '/inc/class-mycommand2-cli.php';
}

Once you’ve written your command and tested it throughly in your local environment, you can commit it to your theme.

We’re working on the ability for you to run custom WP-CLI commands yourself, but for the moment we need to run them for you. When you’ve done so, open a ticket with us with explanation of what you’re trying to accomplish. We’ll check, test, and run it.

↑ Top ↑

Best Practices #

It can be easy to make a minor mistake with your command that causes a lot of pain. We encourage you to do the following:

  • Comment well and provide clear usage instructions. It’s important to be very clear about what each part is doing and why — commenting each step of your logic is a good sanity check. Comments are especially helpful when something maybe doesn’t work as intended and we need to debug to figure out why.
  • If your command is calling wp_update_post() or importing posts, make sure to define( 'WP_IMPORTING', true ); at the top of the related code. This will ensure only the minimum of extra actions are fired.
  • Be as verbose as possible. While operating the command, we’re often asked for progress and estimated time to finish as running scripts in production often takes much longer than it takes in staging environment. The same applies to live run which typically takes much longer than the initial dry run. It’s important for the person running the command to know that something is happening, what’s happening and when the script will finish. Have an opening line in the script and a line for every action the command is performing. For instance:
    public function __invoke( $args, $assoc_args ) {
        
        //... dealing with args
        
        if ( true === $dry_mode ) {
            WP_CLI::line( "===Dry Run===" ); //let us know the dry mode is turned on
        } else {
            WP_CLI::line( "Doing it live!" );
        }
        
        //... defining $query_args and creating new WP_Query object
        //set variables for holding stats printed on the end of the run
        $updated = 0;
        $missed = 0;
    
        do {
    
            //let us know how many posts are about to be processed
            WP_CLI::line( sprintf( "Processing %d posts at offset of %d of %d total found posts", count( $query->posts ), $offset, $query->found_posts ) );
    
            // do stuff
    
            // let us know what's going to happen
            WP_CLI::line( sprintf( "Updating %s meta for post_id: " ), 'some_meta_key', $post_id );
    
            //save result of update/delete functions
            $updated = update_post_meta( $post_id, 'some_meta_key', sanitize_text_field( $some_meta_value ) );
    
            if ( $updated ) {
    
                //let us know whether the update was successful
                WP_CLI::line( "Success: Updated post_meta '%s' for post_id %d with value %s", 'some_meta_key', $post_id, serialize( $some_meta_value ) );
                $updated++; //count successful updates
    
            } else {
    
                //and provide us with some helpful debug info in case it was not
                WP_CLI::line( "Error: Failed to update post_meta '%s' for post_id %d with value %s", 'some_meta_key', $post_id, serialize( $some_meta_value ) ); //some values, eg.: WP_Error object should be serialized in order to print something meaningful
                $missed++; //as well as errors/skips
    
            }
    
            //free up memory
            $this->stop_the_insanity();
    
            $query_args['paged']++;
            $query = new WP_Query( $query_args );
    
        } while( $query->have_posts() );
    
        //let us know what's the result of the script
        WP_CLI::line( "Finished the script. Updated: %d. Missed: %d", $updated, $missed );
    }
    
  • Use the progress bar. The command might also take advantage of a progress bar class which is available:
    public function __invoke( $args, $assoc_args ) {
        
        //... dealing with args
        
        $posts_per_page = 100; //posts per page will be used for ticks
        
        //... defining $query_args and creating new WP_Query object
        
        //create new progress bar, provide number of all posts we'll be dealing with as well as a size of a batch processed before the first/next tick will happen
        $progress = new cliprogressBar( sprintf( 'Starting the command. Found %d posts', $query->found_posts ), $query->found_posts, $posts_per_page );
        
        $progress->display();
        
        do {
            
            WP_CLI::line( sprintf( "Processing %d posts at offset of %d of %d total found posts", count( $query->posts ), $offset, $query->found_posts ) );
            
            //...
            
            $progress->tick( $posts_per_page ); //tick
            
            // Free up memory
            $this->stop_the_insanity();
    
            $query_args['paged']++;
            $query = new WP_Query( $query_args );
    
        } while ( $query->have_posts() );
    
        $progress->finish(); //done
    
        WP_CLI::line( "Finished the script. Updated: %d. Missed: %d", $updated, $missed );
    }
    
  • It’s a good idea to default your command to do a test run without affecting live data. Add an argument to allow a “live” run. This way, we can compare what the actual impact is versus the expected impact.
    A good way to do this is to do:

    $dry_mode = ! empty ( $assoc_args['dry-run'] );
    if( ! $dry_mode ) {
    	WP_CLI::line( " * Removing {$user->user_login} ( {$user->ID} )... " );
    	$remove_result = remove_user_from_blog( $user->ID, $blog_id );
    	if ( is_wp_error( $remove_result ) ) {
    		$failed_to_remove[] = $user;
    	}
    } else {
    	WP_CLI::line( " * Will remove {$user->user_login} ( {$user->ID} )... " );
    }

    If your code modifies existing data we will ask for a dry run option so that we can confirm with you that things are good

  • Check your CLI methods have the necessary arguments. WP CLI passes 2 arguments, $args and $assoc_args, to each command, you’ll need these to implement dry run options. You can take advantage of wp_parse_args for setting default values for optional parameters:
    $args_assoc = wp_parse_args( $args_assoc, array(
        'dry-run' => true
        'post-meta' => 'some_default_post_meta' //etc...
    ) );
    
  • If you’re modifying lots of data on a live site, make sure to include sleep() in key places. This will help with load associated with cache invalidation and replication. We also recommend using the WPCOM_VIP_CLI_Command methods stop_the_insanity() to clear memory after having processed 100 posts. If you are processing a large number of posts using the start_bulk_operation() and end_bulk_operation() class methods to disable functionality that is often problematic with large write operations.
  • Prepare the command for long runs. The vast majority of WP-CLI commands deals with lots of data on live site. The command should be prepared for processing those without exhausting memory and overloading the database. Make sure to call stop_the_insanity() method and include sleep() in key places (the sleep() will help with load associated with cache invalidation and replication.) Good start is to call $this->stop_the_insanity() after processing (updating, deleting …) 100 posts. Every command using get_posts() or WP_Query should also call $this->stop_the_insanity() after looping over 100 posts at max – this will allow the command to run without interruptions.
  • Prepare the command for restart. Even if the sleep and stop_the_insanity functions are in place, command might die in the middle of its run. Commands dealing with a lot of posts or other long-running commands should be prepared for restart. You might either design them to be idempotent (meaning they can safely be run multiple times) or provide operator an option to start from certain point, perhaps using an offset argument or other suitable mean.
  • Define all constants which are standard in WordPress for performed actions. For instance, if you’re writing an importer, make sure to define( 'WP_IMPORTING', true ); at the top of your subcommand. This will ensure only the minimum of extra actions are fired and will make the command faster.
  • Use WP-CLI::Error only if you want to interrupt the command. Using WP-CLI::Error will result in interrupting the command’s run. Sometimes you just want to know about the error and have it logged for further investigation or just for knowing what did not went as expected. Some “errors” are also not errors, but are expected (You don’t want to update post which does not meet certain conditions etc.). In those cases, you should be using WP_CLI::Line with custom debugging information as this won’t make the command to exit and stop further execution.
  • Direct Database Queries will probably break in unexpected ways. Use core functions as much as possible. WP-CLI loads WordPress core as well as your theme and thus makes all standard WordPress and your theme’s functions available to you in the command. Take advantage of those when possible by using direct SQL queries (specifically those that do UPDATEs or DELETEs) will cause the caches to be invalid. In some cases if a direct SQL query is required, only do SELECTs. Do any write operation using the core WordPress functionality. You may want to remove certain hooks from wp_update_post or do other actions to get the desired behaviour. In some rare contexts, a direct SQL query could be a better choice, but it must be followed by clean_post_cache().While we’re not allowing direct SQL queries for plugins and themes on our platform, for WP-CLI commands it’s sometimes better to do direct SQL query for two reasons: you want to prevent certain hooks from being triggered and/or WP_Query might be pretty expensive for what you need.When building your custom direct SQL queries, remember to properly sanitize the input as you’ll miss the advantage of core’s sanity checks. You should always use $wpdb->prepare method:
    global $wpdb;
    $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title = %s AND ID > %d", $post_title, $min_post_id ) );
    

    When dealing with “LIKE” statement, use $wpdb->esc_like method:

    $like = '%' . $wpdb->esc_like( $args['search'] ) . '%';
    $query = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} as p AND ((p.post_title LIKE %s) OR (p.post_name LIKE %s))", $like, $like );
    

    When updating posts with direct SQL queries, make sure to flush associated cache so the updates will be visible on your site before the cache expires:

    $wpdb->update(
        $wpdb->posts, //table
        array(
            'post_content' => sanitize_text_field( $post_content ) //data should not be SQL escaped, but they should be sanitized
        ), //data
        array( 'ID' => intval( $post_id ) ), //where
        array( '%s' ), //data format
        array( '%d' ) //where format
    );
    clean_post_cache( $post_id ); //clean the cache, else the changes would not be reflected until the cache expires
    
  • When in doubt, ask us!

↑ Top ↑

FAQ #

How do I modify all the posts?

Without a no-LIMIT query, it can be confusing how you would modify all your posts. The problem is that a no-LIMIT query just won’t work in most situations. If the query takes longer than 30 seconds, it will timeout and fail. The solution is use smaller queries and page through the results.

For example:

<?php

class Test_CLI_Command extends WPCOM_VIP_CLI_Command {

	/**
	 * CLI command that takes a metakey (required) and post category (optional)
	 * and publishes all pending posts once they have have had their metakeys updated.
	 *
	 * @subcommand update-metakey
	 * @synopsis --meta-key=<metakey> [--category=<category>] [--dry-run]
	 */
	public function update_metakey( $args, $assoc_args ) {
		$this->start_bulk_operation(); // Disable term counting, Elasticsearch indexing, and PushPress.

		$posts_per_page = 100;
		$paged          = 1;
		$count          = 0;

		// Meta key value is required, otherwise an error will be returned.
		if ( isset( $assoc_args['meta-key'] ) ) {
			$meta_key = $assoc_args['meta-key'];
		} else {
			/*
			 * Caution: calling WP_CLI::error stops the execution of the command.
			 * Use WP_CLI::error only in case you want to stop the execution. Use
			 * WP_CLI::warning or WP_CLI::line for non-blocking errors.
			 */
			WP_CLI::error( 'Must have --meta-key attached.' );
		}

		// Category value is optional.
		if ( isset( $assoc_args['category'] ) ) {
			$cat = $assoc_args['category'];
		} else {
			$cat = '';
		}

		// If --dry-run is not set, then it will default to true.
		// Must set --dry-run explicitly to false to run this command.
		if ( isset( $assoc_args['dry-run'] ) ) {
			/*
			 * passing `--dry-run=false` to the command leads to the `false` value being
			 * set to string `'false'`, but casting `'false'` to bool produces `true`.
			 * Thus the special handling.
			 */
			if ( 'false' === $assoc_args['dry-run'] ) {
				$dry_run = false;
			} else {
				$dry_run = (bool) $assoc_args['dry-run'];
			}
		} else {
			$dry_run = true;
		}

		// Let the user know in what mode the command runs.
		if ( $dry_run ) {
			WP_CLI::line( 'Running in dry-run mode.' );
		} else {
			WP_CLI::line( 'We\'re doing it live!' );
		}

		do {
			$posts = get_posts( array(
				'posts_per_page'   => $posts_per_page,
				'paged'            => $paged,
				'category'         => $cat,
				'post_status'      => 'pending',
				'suppress_filters' => 'false',
			));

			foreach ( $posts as $post ) {
				if ( ! $dry_run ) {
					update_post_meta( $post->ID, $meta_key, 'true' );
					wp_update_post( array( 'post_status' => 'publish' ) );
				}
				$count++;
			}

			// Pause.
			WP_CLI::line( 'Pausing for a breath...' );
			sleep( 3 );

			// Free up memory.
			$this->stop_the_insanity();

			/*
			 * At this point, we have to decide whether or not to increase the value of $paged
			 * variable. In case a value which is being used for querying the posts (like post_status
			 * in our example) is being changed via the command, we should keep the WP_Query starting
			 * from the beginning in every iteration. If the any value used for querying the posts
			 * is not being changed, then we need to update the value in order to walk through all the posts.
			 */
			// $paged++;

		} while ( count( $posts ) );

		if ( false === $dry_run ) {
			WP_CLI::success( sprintf( '%d posts have successfully been published and had their metakeys updated.', $count ) );
		} else {
			WP_CLI::success( sprintf( '%d posts will be published and have their metakeys updated.', $count ) );
		}
		$this->end_bulk_operation(); // Trigger a term count as well as trigger bulk indexing of Elasticsearch site.
	}

	/**
	 * CLI command that takes a taxonomy (required) and updates terms in that
	 * taxonomy by removing the "test-" prefix.
	 *
	 * @subcommand update-terms
	 * @synopsis --taxonomy=<taxonomy> [--dry_run]
	 */
	public function update_terms( $args, $assoc_args ) {
		$count = 0;

		$this->start_bulk_operation(); // Disable term counting, Elasticsearch indexing, and PushPress.

		// Taxonomy value is required, otherwise an error will be returned.
		if ( isset( $assoc_args['taxonomy'] ) ) {
			$taxonomy = $assoc_args['taxonomy'];
		} else {
			/*
			 * Caution: calling WP_CLI::error stops the execution of the command.
			 * Use WP_CLI::error only in case you want to stop the execution. Use
			 * WP_CLI::warning or WP_CLI::line for non-blocking errors.
			 */
			WP_CLI::error( 'Must have a --taxonomy attached.' );
		}

		// If --dry-run is not set, then it will default to true.
		// Must set --dry-run explicitly to false to run this command.
		if ( isset( $assoc_args['dry-run'] ) ) {
			/*
			 * passing `--dry-run=false` to the command leads to the `false` value being
			 * set to string `'false'`, but casting `'false'` to bool produces `true`.
			 * Thus the special handling.
			 */
			if ( 'false' === $assoc_args['dry-run'] ) {
				$dry_run = false;
			} else {
				$dry_run = (bool) $assoc_args['dry-run'];
			}
		} else {
			$dry_run = true;
		}

		// Let he user know in what mode the command runs.
		if ( $dry_run ) {
			WP_CLI::line( 'Running in dry-run mode.' );
		} else {
			WP_CLI::line( 'We\'re doing it live!' );
		}

		$terms = get_terms( array( 'taxonomy' => $taxonomy ) );

		foreach ( $terms as $term ) {
			if ( ! $dry_run ) {
				wp_update_term( $term-&gt;term_id, $term->taxonomy, array(
					'name' => str_replace( 'test ', '', $term->name ),
					'slug' => str_replace( 'test-', '', $term->slug ),
				) );
			}
			$count++;
		}

		$this->end_bulk_operation(); // Trigger a term count as well as trigger bulk indexing of Elasticsearch site.

		if ( false === $dry_run ) {
			WP_CLI::success( sprintf( '%d terms were updated.', $count ) );
		} else {
			WP_CLI::success( sprintf( '%d terms will be updated.', $count ) );
		}
	}
}

WP_CLI::add_command( 'test-command', 'Test_CLI_Command' );

Ready to get started?

Drop us a note.

No matter where you are in the planning process, we’re happy to help, and we’re actual humans here on the other side of the form. 👋 We’re here to discuss your challenges and plans, evaluate your existing resources or a potential partner, or even make some initial recommendations. And, of course, we’re here to help any time you’re in the market for some robust WordPress awesomeness.