Tech Stuff

WP-CLI, Just For Multisite Fun

WP-CLI is full of useful tools, but getting them to do serious work isn’t so straightforward. The docs are all written in terms of one-liners.

Problem One

On a 12,000-site multisite installation I was using two network-activated privacy plugins. Both had lapsed into the unsupported category in the WordPress repository, so I wanted to get rid of them and replace them with new ones. The primary tool, Authenticator, doesn’t have any options. Set-it-and-forget-it — but I wanted the root blog to be public, so network activation wasn’t going to cut it. I needed to activate the new plugin individually across all the sites. Clearly a job for WP-CLI.

wp site list --field=url | xargs -n1 -I % wp --url=% plugin activate authenticator

Based on a fairly typical one-liner example. This ran for couple of hours, and when it was done I de-activated the old network-activated plugin, and de-activated Authenticator on the root site. I used similar code to activate the Disable Feeds plugin.

Problem Two

So far, training wheels. Some of the tasks I needed to get done on my two installations — 1,400 and 12,000 sites — were a little more complicated. Sure I could use shell scripting — but I don’t know it well enough (make that, at all) to do anything more than the example above. So by cobbling together little bits and pieces of exec(..) commands and loops in a PHP script, I managed to do some complicated things.

On the smaller installation, there were three different obsolete privacy plugins. Two of them were basic to locking out non-authenticated users, the other was there to prevent the feeds from being active. They were each used inconsistently by several hundred of the sites, but not all of them. What I needed to do was find the blogs that were using the old versions, deactivate them, and then activate two new privacy plugins for those sites only. I wrapped these up in a PHP script.

Throughout these examples I’ve added linebreaks for readability.


exec('wp site list --field=url --archived=0', $output);
$user = 'super_admin_account';
foreach($output as $url) {
  echo $url . "\n";
  $pluginlist = array();
  exec("wp plugin list --status=active --url=$url --skip-themes --skip-plugins \
      --user=$user", $pluginlist);
  foreach($pluginlist as $pluginentry) {
    $fields = preg_split('/\s+/',$pluginentry);
    if ( 'private-wordpress-access-control-manager' ==  $fields[0])  {
      echo "\n\ncan deactivate " . $fields[0] . " on $url\n";
      echo exec("wp --url=$url plugin deactivate " . $fields[0]);
      echo exec("wp --url=$url plugin activate authenticator");
      echo exec("wp --url=$url plugin activate disable-feeds");


Let’s walk through that line-by-line. The first exec call grabs the list of URLs through WP-CLI, and stores that list in the array $output. We’ll skip any archived sites.

Next set a variable for the user account, in this case whatever your main super-admin account is.

Then, cycle through each item in the $output array, storing it in the variable $url. Feed that into the WP-CLI command to list the plugins — only active ones, only for that URL, skip theme and plugin files because they cause nothing but heartache and spurious error messages, under the previously set user account — and store them in the $pluginlist array. {I’ve taken the precaution of clearing that array first because it had a habit of pushing to new entries onto the existing array.}

Now you’ll cycle through $pluginlist one at a time. After splitting the list on whitespace, the first item in the resulting array is going to be the slug of the plugin. If it’s ‘private-wordpress-access-control-manager’ (earlier I did a version that looks for the two others) we’re going to de-activate it. Change this line to suit.

echo exec("wp --url=$url plugin deactivate " . $fields[0]);

And then we’ll activate the two replacements.

echo exec("wp --url=$url plugin activate authenticator");
echo exec("wp --url=$url plugin activate disable-feeds");

I’m echoing all this out to the command line so I can see what’s going on. If you like to live on the edge you can skip the echo part.

Problem Three

I’ve modified all the blogs that were using the Twenty Ten theme, so the oldest one still in production is Twenty Eleven. Back when it came out there was a handy plugin called Twenty Eleven Theme Extensions, which gave a few new features for headers, widgets, menus, etc. And once upon a time when it was my default for new blogs I had it network-activated; but obviously that’s not necessary anymore. But I would like to have it activated on those blogs that are still using the Twenty Eleven theme.


exec('wp site list --field=url --archived=0 --skip-themes \
     --skip-plugins', $output);

foreach($output as $url) {
	$messages .= $url . "\n";
	$active_theme = exec("wp theme list --status=active --url=$url \
           --fields=name --format=csv");
	if ( 'twentyeleven' == $active_theme ) {
		$messages .= "TwentyEleven is active for $url\n";
		$messages .= exec("wp plugin activate twenty-eleven-theme-extensions \
                     --url=$url") . "\n\n";
echo $messages;

Again we’ll start with the wp site list command, putting all non-archived sites into the array $output.

Time for a couple of warnings: if you’ve got redirection in place some of them aren’t going to work. But since they’re being redirected to another resource anyway it’s probably unimportant. Also, depending on the error reporting level in your php.ini, you may get a cluttered result of PHP warnings when certain badly written themes are in place. That’s why I start putting all the output into a variable, $messages. Then, despite all the PHP-generated messages my result output is right there at the end.

The $active_theme is just that. For this command, only the last line of the response from wp is needed, so I skip putting it into an array. If it’s ‘twentyeleven’ then boom, I activate the twenty-eleven-theme-extensions plugin.

You can use this for any theme that has one or more required/recommended plugins. There are a lot of them.

Problem Four

Then it gets hairier. I used a plugin that provides an authentication box in a widget. Simple enough, but then the plugin went out of support. Call me paranoid but why wait for a vulnerability to show up? Might as well replace it.

The thing is, with a wide variety of available themes, each with its own collection of sidebars, going through them all manually to…

  1. See if it’s using the plugin
  2. If so activate the new one
  3. Figure out where the widget is being used
  4. Put the new one in the right place
  5. Drop the old one
  6. Deactivate the old plugin

..times several hundred is not an option. But it turns out all this is available in the various WP-CLI commands. Here we go…


$old_plugin = 'sidebar-login';
$new_plugin = 'login-sidebar-widget';

$old_widget = 'wp_sidebarlogin';
$new_widget = 'login_wid';

$user = 'your-super-admin-account-name';

$log = fopen('/tmp/sidebarlog.txt', 'w');

exec('wp site list --field=url --archived=0', $output); 

foreach($output as $url) {

  //go through each blog in turn
  $messages = $url . "\n";

  //See if it's using the old plugin
  exec("wp plugin list --status=active --url=$url --skip-themes --skip-plugins \
         --user=$user", $pluginlist);

  foreach($pluginlist as $pluginentry) {
    $fields = preg_split('/\s+/',$pluginentry);
    if ( $old_plugin == $fields[0])  {
       $messages .= "$old_plugin is already active.\n";

  // if the sidebar-login plugin isn't activated continue onwards to the next URL  
  if ( 0 == $loginflag ) continue;
  //Activate the new sidebar login plugin
  $messages .= "activating $new_plugin on $url.\n";
  $messages .= exec("wp --url=$url plugin activate $new_plugin") . "\n"; 
  //Figure out where the widget is being used
  $sidebarlist = array();
  exec("wp sidebar list --format=csv --fields=name,id --url=$url --skip-plugins \
        --user=$user", $sidebarlist);
  foreach($sidebarlist as $sidebar) {

     //we'll have a csv of all the sidebar names and IDs
     $messages .= "working with $sidebar\n";

     //re-set this sidebar's details
     $sidebar_detail = array();
     $sidebar_detail = explode(',',$sidebar);

     //re-set the widget list
     $widgetlist = array();

     //echo the human-readable name
     $messages .= "  sidebar name: " . $sidebar_detail[0]  . "\n";

     //fetch the list of widgets in the sidebar
     exec("wp widget list " . $sidebar_detail[1] . " --url=$url \
          --fields=name,id,position --format=csv", $widgetlist);
     $messages .= "    working with " . $sidebar_detail[1] . "\n";
     foreach($widgetlist as $widget) { 

        //name, id and position
        $messages .= "    widget " . $widget . "\n";
        $widget_detail = array();
        $widget_detail = explode(',', $widget);
        if ( $old_widget == $widget_detail[0] ) {
          //put in the new widget
          $messages .= "adding $new_widget to " . $sidebar_detail[1] . \
               " in location " . $widget_detail[2] . "\n";
          $messages .= exec("wp widget add $new_widget " . $sidebar_detail[1] . ' ' . \
               $widget_detail[2] . " --url=$url --user=$user") . "\n";

          //and remove the old
          $messages .= "removing $old_widget of id " . $widget_detail[1] . \
                 " from " . $sidebar_detail[1] . "\n";
          $messages .= exec("wp widget delete " . $widget_detail[1] . " \
                  --url=$url --user=$user") . "\n";

  //Finally de-activate the old plugin
  $messages .= "Deactivating $old_plugin on $url\n";
  $messages .= exec("wp plugin deactivate $old_plugin --url=$url --user=$user") . "\n";

  //a little extra whitespace for readability
  $messages .= "\n";
  fwrite($log, $messages);


I’ve commented in explanations of what each chunk is doing. In the interest of sanity checking I have the system write all the results into a log file in my /tmp directory so I can backtrack if something went wrong. The results look something like this. Most of it is just keeping track of where we are, I’ve bolded the lines where actual work is being done. :
sidebar-login is already active.
activating login-sidebar-widget on
Success: Activated 1 of 1 plugins.
working with name,id
  sidebar name: name
    working with id
working with Sidebar,sidebar-1
  sidebar name: Sidebar
    working with sidebar-1
    widget name,id,position
    widget search,search-2,1
    widget wp_sidebarlogin,wp_sidebarlogin-3,2
adding login_wid to sidebar-1 in location 2
Success: Added widget to sidebar.
removing wp_sidebarlogin of id wp_sidebarlogin-3 from sidebar-1
Success: Deleted 1 of 1 widgets.
    widget recent-posts,recent-posts-2,3
    widget categories,categories-2,4
    widget recent-comments,recent-comments-2,5
working with "Content Bottom 1",sidebar-2
  sidebar name: "Content Bottom 1"
    working with sidebar-2
    widget name,id,position
working with "Content Bottom 2",sidebar-3
  sidebar name: "Content Bottom 2"
    working with sidebar-3
    widget name,id,position
working with "Inactive Widgets",wp_inactive_widgets
  sidebar name: "Inactive Widgets"
    working with wp_inactive_widgets
    widget name,id,position
    widget pages,pages-2,1
    widget calendar,calendar-2,2
    widget text,text-2,3
    widget rss,rss-2,4
    widget tag_cloud,tag_cloud-2,5
    widget nav_menu,nav_menu-2,6
    widget wp_sidebarlogin,wp_sidebarlogin-2,7
adding login_wid to wp_inactive_widgets in location 7
Success: Added widget to sidebar.
removing wp_sidebarlogin of id wp_sidebarlogin-2 from wp_inactive_widgets
Success: Deleted 1 of 1 widgets.
Deactivating sidebar-login on
Success: Deactivated 1 of 1 plugins.

Problem Five

As of version 4.3, comments on pages are automatically disabled. This was a terrific thing because who needs comments on a “contact us” page? But — if you have a big installation with blogs created prior to that, comments are still on.

We’ve been using a network-activated plug-in for that, but it would be nice to disable it. One less thing, right? But I have to go through and turn off comments on every page on every site first.

So, I used this:


$user = "your_super_admin";
$log = fopen('/tmp/pagecomments.txt', 'w');


exec('wp site list --field=url --archived=0 --ignore-themes --ignore-plugins', $output);

foreach($output as $url) {
	fwrite($log, "\n\n$url\n");
	echo $url . "\n";
	$pages = array();
	$pagelist = array();
	exec("wp post list --field=id --format=ids --post_type=page --url=$url \
             --user=$user --ignore-themes --ignore-plugins", $pagelist);
	$pages = preg_split('/\s+/', $pagelist[0]);
	fwrite($log, implode(', ', $pages) . "\n");
	foreach ( $pages as $page ) {
		echo "$page\n";
		fwrite($log, $page . "\n");
		$message = exec("wp --user=$user post update $page --url=$url \
                       --ignore-themes --ignore-plugins --comment_status=closed") . "\n";
		fwrite($log, $message . "\n");

I added one little refinement, the chdir lets me store these scripts outside of my web root, but still have them execute in the wordpress directory where WP-CLI wants you to be.

Some sample lines from the log:

340, 1276, 1273, 1268, 1155, 1039, 658, 571, 537, 507, 494, 498, 367, 370, 360, 359, 358, 346, 181, 343, 337, 338, 336, 334, 335, 333, 332, 331, 330, 329, 328, 327, 345, 326, 213, 171, 106, 2, 20, 14
Success: Updated post 340.

Success: Updated post 1276.

Success: Updated post 1273.

Success: Updated post 1268.

Success: Updated post 1155.

Success: Updated post 1039.


So you can wrap all your WP-CLI commands into exec blocks, and treat the results as PHP variables, then feed them right back in to another WP-CLI command. This allows you to do some non-trivial work on large installations, protecting your security and your time.

Please comment if you see anything wrong. I’m confident this code can be refactored and generalized, but you know how it is once you get something working, it’s time to move on to the next thing.

Tom McGee has been building web sites since 1995, and blogging here since 2006. Currently a senior developer at Seton Hall University, he's also a freelance web programmer and musician. Contact him if you have the need for a blog, web site, redesign or custom programming!

Leave a Reply

Your email address will not be published. Required fields are marked *