Skip to content

Backgrounds

How-to Guides

Technical References

Write custom WP-CLI commands

Occasionally, you may find that you need to access or transform large amounts of data on your site. If it’s for more than a dozen posts, it’s usually more efficient to write a custom WP-CLI command (sometimes called a “bin script”), where you can do things such as easily change strings, assign categories, or add post meta across hundreds or thousands of posts.

General Tips

Some general tips to keep in mind when writing your script:

  • Default your command to do a test run without affecting live data. Add an argument to allow a “live” run—this way, you can compare what the actual impact is versus the expected impact:
$dry_mode = ! empty ( $assoc_args['dry-run'] );
if ( ! $dry_mode ) {
	WP_CLI::line( " * Removing {$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_login} ( {$user_id} )... " );
}
  • 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,
		// etc...
    	'post-meta' => 'some_default_post_meta'
	)
);
  • Use WP-CLI::Error only if you want to interrupt the command.  If 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, you should be using WP_CLI::Line or WP_CLI::Warning with custom debugging information as this won’t make the command to exit and stop further execution.  Some “errors” are also not errors, but are expected (i.e. you don’t want to update post which does not meet certain conditions, etc.).
  • Comment well and provide clear usage instructions. It’s important to be very clear about what each part is doing and the reasoning behind the logic. Comments are especially helpful when something maybe doesn’t work as intended and there needs to be debugging.
  • Be as verbose as possible. It’s important when 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:
public function __invoke( $args, $assoc_args ) {

	// ...process args

	// Let user know if command is running dry or live
	if ( true === $dry_mode ) {
		WP_CLI::line( '===Dry Run===' );
	} else {
		WP_CLI::line( 'Doing it live!' );
	}

	// ...define $query_args for WP_Query object
		
	// Set variables for holding stats printed on the end of the run
	$updated = $missed = 0;
	
	do {
		// Let user 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 user know what is happening
		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 user if 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 ) );

			// Count successful updates
			$updated++;
		} else {
			// If not successful, provide some helpful debug info
			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 ) ); // There are some values (eg.: WP_Error object) that should be serialized in order to print something meaningful

			// Count any errors/skips
			$missed++;
			
			// Free up memory
			$this->stop_the_insanity();
			$query_args['paged']++;
			$query = new WP_Query( $query_args );
		}
	} while( $query->have_posts() );
		
	// Let user know result of the script
	WP_CLI::line( "Finished the script. Updated: %d. Missed: %d", $updated, $missed );
}
  • Always use $wpdb->prepare method in direct DB queries as a safeguard against SQL injection attacks and when dealing with “LIKE” statements, use the $wpdb->esc_like method:
global $wpdb;
$results = $wpdb->get_results(
	$wpdb->prepare(
		"SELECT * FROM {$wpdb->posts} WHERE post_title = %s AND ID = %d",
		$post_title,
		$min_post_id
	)
);

$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
);

Best Practices for Scale

With great power comes great responsibility—any small mistake you make with your logic could have negative repercussions across your entire dataset! When you run your command, you also want to prevent it from inadvertently affecting your site’s performance.

Always extend the WPCOM_VIP_CLI_Command class (instead of WP_CLI_Command) provided in the development helpers to utilize its helper functions like stop_the_insanity()

Make sure you require the file that contains your new command (e.g. typically in your functions.php file) and only include it if WP_CLI is defined and true:

// 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';
}

If your command is importing posts or calling wp_update_post(), make sure to define( 'WP_IMPORTING', true ); at the top of the related code to ensure only the minimum of extra actions are fired.

Use the progress bar class to have a better idea of the completion time. While operating the command, the time to finish running scripts in production often takes much longer than it takes in the staging environment (the same applies to live runs versus initial dry runs):

public function __invoke( $args, $assoc_args ) {
	// ...process args
	$posts_per_page = 100; // posts per page will be used for ticks
	
	// ...define $query_args and create new WP_Query object
	
	// 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 WP_CLI\Utils\make_progress_bar(
		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 ) );
		
		// ...do stuff
		
		$progress->tick( $posts_per_page );
		
		// Free up memory.
		$this->stop_the_insanity();
		
		$query_args['paged']++;
		$query = new WP_Query( $query_args );
	} while ( $query->have_posts() );
	
	$progress->finish(); 
		
	WP_CLI::line( "Finished the script. Updated: %d. Missed: %d", $updated, $missed );
}
  • If you’re modifying lots of data on a live site, make sure to prepare your command for long runs. The command should be prepared for processing without exhausting memory and overloading the database:
    • Use sleep() in key places to help with loads associated with cache invalidation and replication.
    • Use the following WPCOM_VIP_CLI_Command helper methods:
      • stop_the_insanity() to clear memory after having processed 100 posts or less to avoid interruptions, especially when using get_posts() or WP_Query
      • When processing a large number of posts, use 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 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 an option to start from certain point, perhaps using an offset argument or other suitable mean.
  • Direct Database Queries will probably break in unexpected ways. Use core functions as much as possible, as WP-CLI loads WordPress core with your theme and plugins, which are available to you in the command. Using direct SQL queries (specifically those that do UPDATEs or DELETEs) will cause the caches to be invalid. If a direct SQL query is required, only do SELECTs, but perform write operations using the core WordPress functionality. You may also want to remove certain hooks from wp_update_post or other actions to get the desired behaviour. In some rare contexts, a direct SQL query could be a better choice for certain reasons, such as preventing certain hooks from being triggered and/or WP_Query being too 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 sanitization checks) and follow it with clean_post_cache() to flush associated cache so 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 sanitized ),
	// Data array( 'ID' => intval( $post_id ) ), // WHERE
	array( '%s' ), // data format
	array( '%d' ) // where format
);

clean_post_cache( $post_id ); // Clean the cache to reflect changes.
  • Using a no-LIMIT query can lead to timeout and failure, especially if it takes longer than 30 seconds. Instead, we recommend using smaller queries and paging through the results:
class Test_CLI_Command extends WPCOM_VIP_CLI_Command {
	/**
	 * Publishes all pending posts once they have had their metakeys updated.
	 * 
	 * Takes a metakey (required) and post category (optional).
	 *
	 * @subcommand update-metakey
	 * @synopsis --meta-key=<meta-key> [--category=<category>] [--dry-run]
	 */
	public function update_metakey( $args, $assoc_args ) {
		// Disable term counting, Elasticsearch indexing, and PushPress. 
		$this->start_bulk_operation();
		
		$posts_per_page = 100;
		$paged = 1;
		$count = 0;
		
		// Meta key 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 it only in case you want to stop the execution. Otherwise, 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;
		}
		
		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 to increase the value of $paged. 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 ) );
		}
		
		// Trigger a term count as well as trigger bulk indexing of Elasticsearch site.
		$this->end_bulk_operation();
	}
		
	/**
	 * Updates terms in that taxonomy by removing the "test-" prefix.
	 * 
	 * Takes a taxonomy (required).
	 *
	 * @subcommand update-terms
	 * @synopsis --taxonomy=<taxonomy> [--dry_run]
	 */
	public function update_terms( $args, $assoc_args ) {
		$count = 0;

		// Disable term counting, Elasticsearch indexing, and PushPress.
		$this->start_bulk_operation(); 
			 
		// Taxonomy value is required, otherwise an error will be returned.
		if ( isset( $assoc_args['taxonomy'] ) ) {
			$taxonomy = $assoc_args['taxonomy'];
		} else {
			WP_CLI::error( 'Must have a --taxonomy attached.' );
		}
			
		if ( isset( $assoc_args['dry-run'] ) ) {
			if ( 'false' === $assoc_args['dry-run'] ) {
				$dry_run = false;
			} else {
				$dry_run = (bool) $assoc_args['dry-run'];
			}
		} else {
			$dry_run = true;
		}
			
		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 ) {
				$args = array(
					'name' => str_replace( 'test ', '', $term->name ),
					'slug' => str_replace( 'test-', '', $term->slug ),
				);
				wp_update_term( $term->term_id, $term->taxonomy, $args );
			}
			$count++;
		}
			
		// Trigger a term count as well as trigger bulk indexing of Elasticsearch site.
		$this->end_bulk_operation();
			
		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' );

How to debug CLI with New Relic

By default WP-CLI commands and Cron events are not monitored by New Relic, but if you would like us to make New Relic available for these please send us a support request.

Last updated: April 09, 2021