This article is written by Nathan Finch and originally published at Toptal

Have you ever found yourself going into the WordPress admin area to update themes, plugins, and WP core? Of course you have. Have you been asked, “Can you create/update/delete all the users on this CSV file?” I’m sure you’ve run into that too. Have you tried migrating a site and wished there were a plugin or third-party tool you could reach for to do the job? I know I have!

Automating Repetitive Tasks with WP-CLI

There is a very powerful tool available to help you with these tasks and more. Before I tell you about it, I would like to set up a quick anecdote.

The Problem: In a recent project, there were several programmatic tasks I needed to repeat on a regular basis. One task in particular involved updating user-level permissions based on evidence of membership-level purchase or subscription. If the company couldn’t find a payment from the user for a particular membership level, they wanted the membership level removed from the user. Why was this needed? Perhaps a member stopped a subscription, but an event did not fire, and so the member still has access even though they’re not paying for it (yikes!). Or perhaps someone was on a trial offer, but that offer expired and the client still has a subscription (also yikes!).

The Solution: Instead of going into the admin panel and manually deleting hundreds (maybe thousands) of subscriptions, I opted to reach for one of my favorite WordPress tools, WP-CLI, which fixed the problem in a few keystrokes.

In this post, I want to introduce you to WP-CLI (assuming you are not already close friends), walk you through a simple custom command I wrote for this particular situation, and give you some ideas and resources for using WP-CLI in your own development.

What Is WP-CLI?

If you have never heard of WP-CLI before, you’re not alone. The project, while several years old, seemed to fly under the WordPress radar for a while. Here’s a brief description of what WP-CLI is and does from the official website:

“WP-CLI is a set of command-line tools for managing WordPress installations. You can update plugins, set up multisite installs and much more, without using a web browser.”

The following commands show you the power of WP-CLI out of the box:

  • wp plugin update --all updates all updateable plugins.
  • wp db export exports a SQL dump of your database.
  • wp media regenerate regenerates thumbnails for attachments (e.g., after you change sizing in your theme).
  • wp checksum core verifies that WordPress core files have not been tampered with.
  • wp search-replace searches for and replaces strings in the database.

If you explore more commands here, you will see there are plenty of available commands for repetitive tasks every WordPress developer or site maintainer does on a daily or weekly basis. These commands have saved me countless hours of pointing, clicking, and waiting for page reloads over the course of the year.

Are you convinced? Ready to get started? Great!

You will need to have WP-CLI installed with your WordPress (or globally on your local machine). If you have not yet installed WP-CLI on your local development environment, installation instructions can be found on the website here. If you’re using Varying Vagrant Vagrants (VVV2), WP-CLI is included. Many hosting providers also have WP-CLI included on their platform. I will assume you have this successfully installed moving forward.

Using WP-CLI to Solve the Problem

To solve the problem of the repetitive tasks, we need to make a custom WP-CLI command available to our WordPress install. One of the easiest ways to add functionality to any site is to create a plugin. We will use a plugin in this instance for three main reasons:

  1. We will be able to turn off the custom command if we do not need it
  2. We can easily extend our commands and subcommands all while keeping things modular.
  3. We can maintain functionality across themes and even other WordPress installs.

Creating the Plugin

To create a plugin, we need to add a directory to our /plugins directory in our wp-content directory. We can call this directory toptal-wpcli. Then create two files in that directory:

  • index.php, which should only have one line of code: <?php // Silence is golden
  • plugin.php, which is where our code will go (You can name this file whatever you want.)

Open the plugin.php file and add the following code:

<?php
/**
 * Plugin Name: TOPTAL WP-CLI Commands
 * Version: 0.1
 * Plugin URI: https://n8finch.com/
 * Description: Some rando wp-cli commands to make life easier...
 * Author: Nate Finch
 * Author URI: https://n8finch.com/
 * Text Domain: toptal-wpcli
 * Domain Path: /languages/
 * License: GPL v3
 */
/**
 * NOTE: THIS PLUGIN FILE WILL NOT WORK IN PRODUCTION AS IS AND IS ONLY FOR DEMONSTRATION PURPOSES!
 * You can of course take the code and repurpose it:-).
 */
if ( !defined( 'WP_CLI' ) && WP_CLI ) {
    //Then we don't want to load the plugin
    return;
}

There are two parts to these first several lines.

First, we have the plugin header. This information is pulled into the WordPress Plugins admin page and allows us to register our plugin and activate it. Only the plugin name is required, but we should include the rest for anyone who might want to use this code (as well as our future selves!).

Second, we want to check that WP-CLI is defined. That is, we are checking to see if the WP-CLI constant is present. If it is not, we want to bail and not run the plugin. If it is present, we are clear to run the rest of our code.

In between these two sections, I’ve added a note that this code should not be used “as is” in production, since some of the functions are placeholders for real functions. If you change these placeholder functions to real, active functions, feel free to delete this note.

Adding the Custom Command

Next, we want to include the following code:

class TOPTAL_WP_CLI_COMMANDS extends WP_CLI_Command {

	function remove_user() {

		echo "\n\n hello world \n\n";

  	}

}

WP_CLI::add_command( 'toptal', 'TOPTAL_WP_CLI_COMMANDS' );

This block of code does two things for us:

  1. It defines the class TOPTAL_WP_CLI_COMMANDS, which we can pass arguments into.
  2. It assigns the command toptal to the class, so we can run it from the command line.

Now, if we execute wp toptal remove_user, we see:

$ wp toptal hello


 hello world

This means our command toptal is registered and our subcommand remove_user is working.

Setting Up Variables

Since we are bulk processing removing users, we want to set up the following variables:

// Keep a tally of warnings and loops
  $total_warnings = 0;
  $total_users_removed = 0;

// If it's a dry run, add this to the end of the success message
  $dry_suffix = '';

// Keep a list of emails for users we may want to double check
  $emails_not_existing = array();
  $emails_without_level = array();

// Get the args
  $dry_run = $assoc_args['dry-run'];
  $level = $assoc_args['level'];
  $emails = explode( ',', $assoc_args['email'] );

The intent of each of the variables is as follows:

  • total_warnings: We will tally a warning if the email does not exist, or if the email is not associated with the membership level we are removing.
  • $total_users_removed: We want to tally the number of users removed in the process (see caveat below).
  • $dry_suffix: If this is a dry run, we want to add wording to the final success notice.
  • $emails_not_existing: Stores a list of emails that do not exist.
  • $emails_without_level: Stores a list of emails that do not have the specified level.
  • $dry_run: A boolean that stores whether the script is doing a dry run (true) or not (false).
  • $level: An integer representing the level to check and possibly remove.
  • $email: An array of emails to check against the given level. We will loop through this array

With our variables set, we are ready to actually run the function. In true WordPress fashion, we will run a loop.

Writing the Function Itself

We start by creating a foreach loop to cycle through all the emails in our $emails array:

// Loop through emails
foreach ( $emails as $email ) {

	// code coming soon

} // end foreach

Then, we add a conditional check:

// Loop through emails
foreach ( $emails as $email ) {
	//Get User ID
	$user_id = email_exists($email);

	if( !$user_id ) {

		WP_CLI::warning( "The user {$email} does not seem to exist." );

		array_push( $emails_not_existing, $email );

		$total_warnings++;

		continue;
	}
} // end foreach

This check ensures we have a registered user with the email we are checking. It uses the email_exists()function to check if there is a user with that email. If it does not find a user with that email, it throws a warning so that we know on our terminal screen that the email was not found:

$ wp toptal remove_user --email=me@me.com --dry-run

Warning: The user me@me.com does not seem to exist.

The email is then stored in the $emails_not_existing array for display later. Then we increment the total warning by one and continue through the loop to the next email.

If the email does exist, we will use the $user_id and the $level variables to check if the user has access to the level. We store the resulting boolean value in the $has_level variable:

// Loop through emails
foreach ( $emails as $email ) {
	//Get User ID
	$user_id = email_exists($email);

	if( !$user_id ) {

		WP_CLI::warning( "The user {$email} does not seem to exist." );

		array_push( $emails_not_existing, $email );

		$total_warnings++;

		continue;
	}

	// Check membership level. This is a made up function, but you could write one or your membership plugin probably has one.
	$has_level = function_to_check_membership_level( $level, $user_id );

} // end foreach

Like most functions in this example, this function_to_check_membership_level() function is fabricated, but most membership plugins should have helper functions to get you this information.

Now, we’ll move on to the main action: removing the level from the user. We will use an if/else structure, which looks like this:


foreach ( $emails as $email ) {

	// Previous code here...


	// Check membership level. This is a made up function, but you could write one or your membership plugin probably has one.
	$has_level = function_to_check_membership_level( $level, $user_id );

	if ( $has_level ) {

		if ( !$dry_run ) {

		// Deactivate membership level. This is a made up function, but you could write one or your membership plugin probably has one.
			function_to_deactivate_membership_level( $level, $user_id, 'inactive' );

		}

		WP_CLI::success( "Membership canceled for {$email}, Level {$level} removed" . PHP_EOL );

		$total_users_removed++;

	} else {

		WP_CLI::warning( "The user {$email} does not have Level = {$level} membership." );

		array_push( $emails_without_level, $email );

		$total_warnings++;
	}

	// We could echo something here to show that things are processing...

} // end foreach

If the value of $has_level is “truthy,” meaning the user has access to the membership level, we want to run a function to remove that level. In this example, we will use the function_to_deactivate_membership_level()function to perform this action.

However, before we actually remove the level from the user, we want to enclose that function in a conditional check to see if this is actually a dry-run. If it is, we do not want to remove anything, only report that we did. If it is not a dry-run, then we will go ahead and remove the level from the user, log our success message to the terminal, and continue looping through the emails.

If, on the other hand, the value of $has_level is “falsey,” meaning the user does not have access to the membership level, we want to log a warning to the terminal, push the email to the $emails_without_levelarray, and continue looping through the emails.

Finishing Up and Reporting

Once the loop has finished, we want to log our results to the console. If this was a dry run, we want to log an extra message to the console:

if ( $dry_run ) {
	$dry_suffix = 'BUT, nothing really changed because this was a dry run:-).';
}

This $dry-suffix will be appended to the warnings and success notifications that we log next.

Finishing up, we want to log our results as a success message and our warnings as warning messages. We will do so like this:

WP_CLI::success( "{$total_users_removed} User/s been removed, with {$total_warnings} warnings. {$dry_suffix}" );

if ( $total_warnings ) {

	$emails_not_existing = implode(',', $emails_not_existing);
	$emails_without_level = implode(',', $emails_without_level);

	WP_CLI::warning(

		"These are the emails to double check and make sure things are on the up and up:" . PHP_EOL .
		"Non-existent emails: " . $emails_not_existing . PHP_EOL .
		"Emails without the associated level: " . $emails_without_level . PHP_EOL

	);
}

Note that we are using the WP_CLI::success and WP_CLI::warning helper methods. These are provided by WP-CLI for logging information to the console. You can easily log strings, which is what we do here, including our $total_users_removed$total_warnings, and $dry_suffix variables.

Finally, if we did accrue any warnings throughout the runtime of the script, we want to print that information to the console. After running a conditional check, we convert the $emails_not_existing and $emails_without_level array variables into string variables. We do this so we can print them to the console using the WP_CLI::warning helper method.

Adding a Description

We all know comments are helpful to others and to our future selves going back to our code weeks, months, or even years later. WP-CLI provides an interface of short descriptions (shortdesc) and long descriptions (longdesc) which allows us to annotate our command. We will put at the top of our command, after the TOPTAL_WP_CLI_COMMANDS class is defined:


/**
 * Remove a membership level from a user
 *
 * ## OPTIONS
 * --level=<number>
 * : Membership level to check for and remove
 *
 * --email=<email>
 * : Email of user to check against
 *
 * [--dry-run]
 * : Run the entire search/replace operation and show report, but don't save changes to the database.
 *
 * ## EXAMPLES
 *
 * wp toptal remove_user --level=5 --email=me@test.com,another@email.com, and@another.com --dry-run
 *
 * @when after_wp_load
 */

In the longdesc, we define what we expect our custom command to receive. The syntax for the shortdesc and longdesc is Markdown Extra. Under the ## OPTIONS section, we define the arguments we expect to receive. If an argument is required, we wrap it in < >, and if it is optional, we wrap it in [ ].

These options are validated when the command is run; for example, if we leave out the required email parameter, we get the following error:

$ wp toptal remove_user --level=5 --dry-run
Error: Parameter errors:
 missing --email parameter (Email of user to check against)

The ## EXAMPLES section includes an example of what the command could look like when being called.

Our custom command is now complete. You can see the final gist here.

A Caveat and Room for Improvement

It is important to review the work that we have done here to see how the code could be improved, expanded and refactored. There are many areas of improvement for this script. Here are some observations about improvements that could be made.

Occasionally, I have found this script will not remove all the users it logs as “removed.” This is most likely due to the script running faster than the queries can execute. Your experience may vary, depending on the environment and setup in which the script is run. The quick way around this is to repeatedly run with the same inputs; it will eventually zero out and report that no users have been removed.

The script could be improved to wait and validate that a user has been removed before logging the user as actually removed. This would slow down the execution of the script, but it would be more accurate, and you would only have to run it once.

Similarly, if there were errors found like this, the script could throw errors to alert that a level had not been removed from a user.

Another area to improve the script is to allow for multiple levels at a time to be removed from one email address. The script could auto-detect if there were one or more levels and one or more emails to remove. I was given CSV files by level, so I only needed to run one level at a time.

We could also refactor some of the code to use ternary operators instead of the more verbose conditional checks we currently have. I have opted to make this easier to read for the sake of demonstration, but feel free to make the code your own.

In the final step, instead of printing emails to the console in the final step, we could also automatically export them to a CSV or plain text file

Finally, there are no checks to make sure we’re getting an integer for the $level variable or an email or comma-separated list of emails in the $emails variable. Currently, if someone were to include strings instead of integers, or user login names instead of emails, the script would not function (and throw no errors). Checks for integers and emails could be added.

Ideas for Further Automation and Further Reading

As you can see, even in this specific use case, WP-CLI is quite flexible and powerful enough to help you get your work done quickly and efficiently. You may be wondering to yourself, “How can I begin to implement WP-CLI in my daily and weekly development flow?”

There are several ways you can use WP-CLI. Here are some of my favorites:

  • Update themes, plugins, and WP core without having to go into the admin panel.
  • Export databases for backup or perform a quick SQL dump if I want to test a SQL query.
  • Migrate WordPress sites.
  • Install new WordPress sites with dummy data or custom plugin suite setups.
  • Run checksums on core files to make sure they have not been compromised. (There is actually a project underway to expand this to themes and plugins in the WP repo.)
  • Write your own script to check, update, and maintain site hosts (which I wrote about here).

The possibilities with WP-CLI are just about limitless. Here are some resources to keep you moving forward:

This article is written by Nathan Finch and originally published at Toptal

About the Author:  Nathan works primarily in WordPress front-end development—consulting and implementing with Genesis and WooCommerce for SME businesses and enterprise level projects. He uses WordPress, HTML, CSS, PHP, and JavaScript on a daily basis as well as Sass, Git, jQuery, and Grunt. Nathan is constantly exploring and experimenting with other front-end technologies and frameworks like Angular and React.

 

About Michelle Young