Managing User Access

We encourage all VIP clients to manage user access to their websites and GitHub repositories. It’s best to designate primary administrators who will be in charge of adding and removing users. We recommend at least one primary administrator for each client, two would be even better in case one of them is unavailable. Primary administrators should feel empowered to add additional administrators as they see fit while following best practices.

Managing GitHub Access

A collaborator with admin privileges in your GitHub repository will be able to add or remove additional collaborators. Find instructions in GitHub’s Documentation. There are a few points to keep in mind when deciding on permissions for collaborators:

  • Please only add users with read or write permissions as required. Users who would need write access include those who will be committing code to the repository.
  • Admin collaborators can force push (which is blocked in VIP Go environments) after removing restrictions. If they do this, they should restore restrictions afterwards.
  • Please review the full description of every permission level in GitHub’s documentation.

Add a User to Your VIP Go Site

As an Administrator, you are able to add users to your website through wp-admin (also known as the WordPress Dashboard). Add users to your site using the default WordPress user roles, or customized roles unique to your business needs:

  1. Log into your VIP Go site to access wp-admin.
  2. From the sidebar, click on Users.
  3. Next click the Add New button to add a new user to your site.
  4. Fill out the form and select the role that your user should be assigned.
    • Check the Send User Notification option if you want the user to receive an email with a password-set link. If you do not select this option, the user will need to access the login URL (example.com/wp-admin) and use the password reset feature to generate a password.
  5. That’s it! You did it!

Add a User to Your VIP Go Multisite

In a multisite, user management is done at the network (top) level. Only a Super Admin can add users to the network. Once users are added at the network level, Administrators for individual subsites can then add any of these existing users as required.

If you’d like to add a user to your VIP Go multisite as a Super Admin, follow the steps outlined here:

  1. Log into your VIP Go multisite to access wp-admin.
  2. In the admin bar, at the top, hover over My Sites > Network Admin > Users and then click on Users. Adding a user to a multisite through network admin
  3. Next, click the Add New button to add a new user to your site.
  4. Fill out the form. Note this will only prompt you for a username and email address. A password reset link will be sent to the user via email by default.
  5. Now that you’ve added the user, you will be able to edit the user profile. Click Edit user at the top of the screen. (You can also go back to the Users list in Network Admin and hover over the user.)
  6. Check the box that says Grant this user super admin privileges for the Network.
  7. Scroll down to the end of this page and click Update User. Now the user has Super Admin privileges for the entire multisite.
  8. Note, if you ever need to delete this user, you must first remove the Super Admin privileges. To do that, go into the user’s profile, unselect the super admin privileges option, and then update.

Testing your site

When you test your site, we recommend you run all tests on your production environment. Walk through your site using all the functionality of your dashboard, including plugins that you and your team will use on a regular basis. This could include creating test posts and widgets.

In addition to testing backend functionality, you’ll want to look at the frontend to ensure it functions as expected. If anything appears broken or is not working as expected, you might want to take a deeper look at your output and see which errors or warnings need to be addressed.

At a minimum, we recommend the following  tests:

  • Create a post as a user with the “editor” role
  • Create a post as a user with the “author” role
  • Upload an image to the media library
  • Edit a post
  • Delete a post
  • Create a new user
  • Delete a user
  • Change a user’s role
  • Add a widget
  • Modify a widget
  • Verify settings are correct for external services like Google Analytics, Twitter, Facebook, etc.
  • Any features of your editorial workflow that rely on plugins or theme functionality
  • 301 redirects, if any, still work

JavaScript security best practices

The primary vulnerability we need to be careful of in JavaScript is Cross Site Scripting (XSS). In WordPress with PHP, we use escaping functions to avoid that — esc_html(), esc_attr(), esc_url(), etc. Given that, it only seems natural that we would also need to escape HTML in JavaScript.

As it turns out out, however, that’s the wrong way to approach JavaScript security. To avoid XSS, we want to avoid inserting HTML directly into the document and instead, programmatically create DOM nodes and append them to the DOM. This means avoiding .html(), .innerHTML, and other related functions, and instead using .append(), .prepend(), .before(), .after(), and so on.

Here is an example:

jQuery.ajax({
    url: 'http://any-site.com/endpoint.json'
}).done( function( data ) {
    var link = '<a href="' + data.url + '">' + data.title + '</a>';

    jQuery( '#my-div' ).html( link );
});

This approach is dangerous, because we’re trusting that the response from any-site.com includes only safe data – something we can not guarantee, even if the site is our own. Who is to say that data.title doesn’t contain alert( "haxxored");;?

Instead, the correct approach is to create a new DOM node programmatically, then attach it to the DOM:

jQuery.ajax({
    url: 'http://any-site.com/endpoint.json'
}).done( function( data ) {
    var a = jQuery( '<a />' );
    a.attr( 'href', data.url );
    a.text( data.title );

    jQuery( '#my-div' ).append( a );
});

Note: It’s technically faster to insert HTML, because the browser is optimized to parse HTML. The best solution is to minimize insertions of DOM nodes by building larger objects in memory, then insert it into the DOM all at once, when possible.

By passing the data through either jQuery or the browser’s DOM API’s, we ensure the values are properly sanitized and remove the need to inject insecure HTML snippets into the page.

To ensure the security of your application, use the DOM APIs provided by the browser (or jQuery) for all DOM manipulation.

Escaping Dynamic JavaScript Values

When it comes to sending dynamic data from PHP to JavaScript, care must be taken to ensure values are properly escaped. The core function esc_js() helps escape JavaScript for us in DOM attributes, while all other values should be encoded with json_encode().

From the WP Codex on esc_js():

It is intended to be used for inline JS (in a tag attribute, for example onclick=”…”).

If you’re not working with inline JS in HTML event handler attributes, a more suitable function to use is json_encode, which is built-in to PHP.

This approach is incorrect:

var title = '<?php echo esc_js( $title ); ?>';
var content = '<?php echo esc_js( $content ); ?>';
var comment_count = '<?php echo esc_js( $comment_count ); ?>';

Instead, it’s better to use json_encode() (note that json_encode() adds the quotes automatically):

var title = <?php echo wp_json_encode( $title ); ?>;
var content = <?php echo wp_json_encode( $content ); ?>;
var comment_count = <?php echo wp_json_encode( $comment_count ); ?>;

Stripping Tags

It may be tempting to use .html() followed by .text() to strip tags – but this approach is still vulnerable to attack:

// Incorrect
var text = jQuery('<div />').html( some_html_string ).text();
jQuery( '.some-div' ).html( text );

Setting the HTML of an element will always trigger things like src attributes to be executed, which can lead to attacks like this:

// XSS attack waiting to happen
var some_html_string = '<img src="a" onerror="alert('haxxored');" />';

As soon as that string is set as a DOM element’s HTML (even if it’s not currently attached to the DOM!), src will be loaded, will error out, and the code in the onerror handler will be executed…all before .text() is ever called.

The need to strip tags is often indicative of bad practices – remember, always use the appropriate API for DOM manipulation.

// Correct
jQuery( '.some-div' ).text( some_html_string );

Using encodeURIComponent()

When using values as part of a URL, for example when adding parameters to a URL or building a mailto: link the JavaScript variables need to be encoded to be correctly interpreted by the browser. Using encodeURIComponent() will ensure that the characters you use will be properly interpreted by the browser. This also helps prevent some trickery such as adding & which may cause the browser to incorrectly interpret the values you were expecting. You can find more information on this on the OWASP website.

Using DOMPurify

As mentioned above, using jQuery’s .html() function or React’s dangerouslySetInnerHTML() function can open your site to XSS vulnerabilities by treating arbitrary strings as HTML. These functions should be avoided whenever possible. While it’s easy to think content from your own site is “safe,” it can become an attack vector if a user’s account is compromised or if another part of the application is not doing enough validation.

While we recommend that first you try to use structured data and build the HTML inside the JavaScript, that’s not always feasible. If you do need to include HTML strings inside your JavaScript, we recommend using the DOMPurify package to sanitize strings to remove executable elements that could contain attack vectors. This is very similar to how WP_KSES works.

To use DOMPurify you need to include it as such:

/**
 * For Browsers
 */
import DOMPurify from 'dompurify';
// or
const DOMPurify = require('dompurify');

/**
 * For Node.js we need JSDOM's window
 */
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom'); 
const window = (new JSDOM('')).window;
const DOMPurify = createDOMPurify(window);

You can then call it as such:

const clean = DOMPurify.sanitize(dirty);

Here are a few examples taken from the DOMPurify Readme:

DOMPurify.sanitize('<img src=x onerror=alert(1)//>'); // becomes <img src="x">
DOMPurify.sanitize('
abc<iframe/\/src=jAva&amp;Tab;script:alert(3)>def'); // becomes 

abcdef

DOMPurify.sanitize('

<math><mi//xlink:href="data:x,<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3Ealert(4)%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&amp;lt;script&amp;gt;" title="&amp;lt;script&amp;gt;" />">'); // becomes 

<math><mi></mi></math>


DOMPurify.sanitize('
<TABLE>
<tr>
<td>HELLO</tr>

</TABL>'); // becomes 

// =>

<table>
<tbody>
<tr>
<td>HELLO</td>
</tr>
</tbody>

DOMPurify.sanitize('<UL>
<li><A HREF=//google.com>click</UL>
'); // becomes 

<ul>
<li><a href="//google.com">click</a></li>
</ul>
</table>

Other Common XSS Vectors

  • Using eval(). Never do this.
  • Un-whitelisted / un-sanitized data from urls, url fragments, query strings, cookies
  • Including untrusted / unreviewed third-party JS libraries
  • Using outdated / unpatched third-party JS libraries

Helpful Resources

Database queries

Direct database queries should be avoided wherever possible. Instead, it’s best to rely on WordPress API functions for fetching and manipulating data.

Of course this is not always possible, so if any direct queries need to be run here are some best practices to follow:

  • Use filters to adjust queries to your needs. Filters such as posts_where can help adjust the default queries done by WP_Query. This helps keep your code compatible with other plugins. There are numerous filters available to hook into inside /wp-includes/query.php.
  • Make sure that all your queries are protected against SQL injection by making use of $wpdb->prepare and other escaping functions like esc_sql and like_escape.
  • Try to avoid cross-table queries, especially queries which could contain huge datasets such as negating taxonomy queries like the -cat option to exclude posts of a certain category. These queries can cause a huge load on the database servers.
  • Remember that the database is not a tool box. Although you might be able to perform a lot of work on the database side, your code will scale much better by keeping database queries simple and performing necessary calculations and logic in PHP.
  • Avoid using DISTINCT, GROUP, or other query statements that cause the generation of temporary tables to deliver the results.
  • Be aware of the amount of data you are requesting. Make sure to include defensive limits.
  • When creating your own queries in your development environment, be sure to examine the query for performance issues using the EXPLAIN statement. Confirm indexes are being used.
  • Don’t JOIN the users table.
  • Cache the results of queries where it makes sense.

ACF 5 and VIP Go

Advanced Custom Fields (ACF) is a popular plugin that many VIP clients choose to use on their sites. However, ACF is not available for clients on the WordPress.com VIP platform, and has not undergone a full, line-by-line review for use on VIP Go. Clients wishing to use ACF on VIP Go accept the security and performance risks of using it. This page outlines some additional steps needed to make ACF more secure and performant, but should not be interpreted as the equivalent of a line-by-line review, or VIP’s approval of the plugin.

Please note that while ACF 5 can be used on most VIP Go sites, ACF 4 is unavailable. If in doubt about whether ACF is allowed for use on your VIP site, please contact us.

When using ACF 5 and ACF 5 Pro, several additional steps are needed in order to make ACF secure, and avoid performance issues:

  • Hide the Admin UI
  • Define fields in PHP
  • Use taxonomies for searchable fields
  • Avoid the_field and escape
  • Secure fields that allow arbitrary output

Hide the ACF Admin UI

The fields UI can be used to add arbitrary fields, including unsafe fields. For example it can allow an admin to display the passwords of users. You can disable the UI using this filter:


add_filter('acf/settings/show_admin', '__return_false');

Define Fields in PHP

In order to make sure that all ACF usage is secure, define the fields in PHP or local json, rather than at runtime. This way they remained versioned and safe. This can be done via the import export menu of a local developer environment to setup the fields available and export them to PHP.

Documentation on how to do this can be found here on the ACF website.

Alternatively, fields can be defined via the local JSON feature as described here, but keep in mind that saving local JSON will not work in production as the filesystem is read only, nor is it desirable as it would bypass the security benefits.

Being Mindful of Taxonomy Term Storage

If an ACF field is going to be queried, filtered, or searched for in a post query, use the taxonomy data checkbox so that the field is stored as a term, not a post meta value. This ensures performance is not impacted by expensive meta queries on the frontend

the_field and Escaping

the_field has no context as to when or where it is called. So how is it to know if it should be using esc_url, esc_attr or wp_kses_post? It doesn’t, which makes it dangerous from a security point of view. Instead, use get_field in combination with an escaping function, e.g.

$url = get_field( 'custom_link' );
echo esc_url( $url );

Flexible content is the exception to this, and should be clearly marked on usage via comments

Fields That Use Arbitrary Output

If the field types that allow arbitrary output are to be used, they must be accounted for in the acf/format_value and equivalent filters such as acf/format_value/type=textarea.

For example:

function vip_make_acf_text_areas_safe( $value, $post_id, $field ) {
	return wp_kses_post( $value );
}

add_filter('acf/format_value/type=textarea', 'vip_make_acf_text_areas_safe', 10, 3);

This way different escaping can be applied via different `$field` values. Alternatively, if all fields of that type use the same escaping, this can be done instead:

add_filter('acf/format_value/type=textarea', 'wp_kses_post', 10, 1);

For more information, see: https://www.advancedcustomfields.com/resources/acf-format_value/

The File System on VIP Go

The VIP Platform has been designed with for high-scale performance and security. One of the ways we do that is to run all web servers in read-only mode. This helps protect applications on the platform against many common form of attack (e.g. vulnerabilities that allow installation of backdoor shells and other malicious files); allows for automated, high-capacity horizontal scaling; and dynamic image resizing and manipulation.

Media Uploads

The VIP Platform stores all uploaded media in a globally distributed object store called the VIP Files Service. This is seamlessly integrated with your WordPress application and all common operation including uploads, cropping, editing, and deletes work as expected.

All images uploaded to the VIP Files Service can make use of the Photon API, which allows for dynamic resizing and other manipulation (cropping, letterboxing, filters, etc.). Because image sizes all are created on-the-fly, uploads are significantly faster and changes to image sizes is much easier (no need to run scripts to regenerate files).

Programmatic Access to Media Uploads

For programmatic access to media stored in the VIP Files Service, such as uploading and modifying files, you can use several different APIs.

For simple file uploads, the media_handle_sideload, media_sideload_image, or wp_upload_bits functions are very easy to work with.

For more complex interactions, we’ve integrated a custom PHP Stream Wrapper, which allows most filesystem functions (e.g. fopen, fwrite, file_put_contents, etc.) to work with media uploads. This means that most plugins that interact with media uploads will work on the VIP Platform without any modifications. There are some caveats

A few caveats to be aware of:

  • This only works for the uploads directory (i.e. paths that begin with /wp-content/uploads/; other paths will not work).
  • You must use the WordPress function wp_get_upload_dir() or wp_upload_dir() to generate the correct, writeable path (hard-coding a path like /wp-content/uploads/... will not work).
  • Currently, not all filesystem functions and operations are supported (e.g. directory traversal)
  • Not all use cases are ideal or supported (e.g. files with high volumes of updates, such as logging)
  • Because communication with the VIP Files Service happens over HTTP, high number of function calls (e.g. lots of file_exists checks) can result in a performance hit and should be used with caution.

Examples

Here’s a simple example of how to upload a file using PHP functions:


$csv_content = '1,hello,admin';

$upload_dir = wp_get_upload_dir()['basedir'];

$file_path = $upload_dir . '/csv/updated.csv';

file_put_contents( $file_path, $csv_content );

// The file will now be accessible at <a href="https://example-com.go-vip.net/wp-content/uploads/csv/updated.csv">https://example-com.go-vip.net/wp-content/uploads/csv/updated.csv</a>

Here’s an example that parses an uploaded CSV and stores the contents in a variable:


$csv_attachment = get_attached_file( 1234 );

$csv_file = file( $csv_attachment );

$csv_content = array_map( 'str_getcsv', $csv_file );

Local File Operations

There may be cases where you need to perform local manipulations to temporary files (e.g. download a zip, extract its contents, and then upload them to WordPress). For these cases, you can use the system temp directory, which is the only local, writeable path on web servers on the VIP Platform.

A few things to keep in mind:

  • Use get_temp_dir() to find the right path. Or if you need to generate a temporary file, use wp_tempnam().
  • You should clean up and remove all files after they’re no longer needed. Use unlink() to remove those files.
  • Files and directories can only be relied on for the duration of the current request.
    • Each request may be served by a different container and the sequence of requests from a given user are not guaranteed to be served by the same container.
    • Containers are transient and may be created and destroyed due to autoscaling.

If you have any questions about the VIP Files Service or working with the filesystem on the VIP Platform, please don’t hesitate to get in touch with the VIP team so we can work with you on the implementation.

VIP Go and the WordPress REST API– recommendations and requirements

This document describes how your usage of the WordPress REST API can be structured to ensure your application and your WordPress installation are as performant and stable as possible.

“Front-end API requests”: WordPress REST API requests used to generate the front end of the site in some application, e.g. requests made by a NodeJS app which is generating the front end, or from a mobile application, etc.

“WordPress REST API responses”: the response of the WordPress REST API to requests.

General guidelines

Our guidelines and requirements around front-end API requests are as follows:

  • The front end application should handle unexpected responses to front-end API requests robustly and gracefully; when the front end application receives an unexpected result (e.g. because network conditions prevent the request reaching the API or because of a bug in the API code) it should accommodate this eventuality, e.g. serve up stale content from an object cache within the front end application, show an appropriate error page, back off in request frequency to the API endpoint, etc.
  • Only authenticated front-end users should generate authenticated front-end API requests; authenticated requests bypass the VIP Go Varnish cache, authenticating any proportion of front-end API requests can easily cause issues with site stability and uptime.

Our guidelines and requirements around WordPress REST API responses are similar to our requirements for WordPress themes, please bear in mind the following:

  • WordPress REST API responses should be fast and performant; your API stability will be strongly correlated to how swiftly your API endpoints can respond to requests (especially for any authenticated requests which will not benefit from Varnish caching). Fast API responses can be achieved using traditional WordPress performance optimisation techniques, such as using object caching to reduce repetitive expensive operations, avoiding external HTTP requests, etc.
  • WordPress REST API responses to front-end API requests should be cached by VIP Go; VIP Go runs a Varnish caching layer, API requests from your front end application should aim to hit this cache to serve the responses efficiently and from a location nearer your users.
  • WordPress REST API responses to front-end API requests should never cause writes; as traffic increases, requests from client site which cause database writes will easily cause issues with site stability and uptime.

For a significant utilization of the WordPress REST API, e.g. replacing the front end of your site with a Node application or a high usage mobile application, we will ask you to talk over the following points with us:

  • What is the caching strategy for your application: what are you caching, how are you caching, how long are the caches held for, and how will the caches be cleared?
  • Typical profile of requests: For each type of view, what requests will your application make when the caches are cold? What requests will be made when the caches are warm?
  • How fast are your API endpoints in responding to common requests used to generate popular views in your application?
  • What is your test plan for your use of the REST API?
  • What is your rollout plan for your use of the REST API (including a rollback plan)?

We are happy to discuss how best to structure your use of the WordPress REST API, please open a ticket and we’ll be happy to help.

Location

To avoid compatibility issues and reduce complexity, we strongly recommend you do not change the rest_url_prefix from wp-json; i.e. do not move the WordPress REST API endpoints from http://example.com/wp-json/; for example, this can cause issues with various VIP services which utilise the REST API on each VIP Go site.

Caching

By default VIP Go will cache most REST API endpoints for 1 minute. We strongly recommend that your site does not change this to use PURGEs, but we are happy to talk through any specific requirements that you have and advise on the best approach.

If you want to adjust the TTL for REST responses, you can use the `wpcom_vip_rest_read_response_ttl` filter:

add_filter( 'wpcom_vip_rest_read_response_ttl', function( $ttl, $response, $rest_server, $request ) {
    // Cache REST API GET requests for 5 minutes.
    return MINUTE_IN_SECONDS * 5;
}, 10, 4 );

Querying on meta_value

There’s a quick rule of thumb to know if a meta_value will be a problem. Ask yourself:

“Will I be querying for this meta_value using WP_Query?”

If the answer is no, then you’ve got a perfect use case for postmeta values.

If the answer is yes, then the query is likely to have issues with performance and scalability. This is because the WordPress postmeta table has an index on meta_key, but not meta_value.

However, many use cases can be modified to avoid performance problems:

  • Taxonomy Terms – Some meta_value queries can be transformed into taxonomy queries. For example, instead of using a meta_value to filter if a post should be shown to visitors with membership level “Premium”, use a custom taxonomy and a term for each of the membership levels in order to leverage the indexes.
  • Binary Situations – When meta_value is set as a binary value (ex. “hide_on_homepage” = “true”), MySQL will look at every single row that has the meta_key “hide_on_homepage” in order to check for the meta_value “true”. The solution is to change this so that the mere presence of the meta_key means that the post should be hidden on the homepage. If a post shouldn’t be hidden on the homepage, simply delete the “hide_on_homepage” meta_key. This will leverage the meta_key index and can result in large performance gains.
  • Non-binary Situations – Instead of setting meta_key equal to “primary_category” and meta_value equal to “sports”, you can set meta_key to “primary_category_sports”. This enables you to query by primary_category. However, instead of doing get_post_meta( $id, ‘primary_category’), you would need to iterate over possible values of primary_category with get_post_meta( $id, ‘primary_category_sports’). If you need to do both, you could use a ‘primary_category’ and a ‘primary_category_sports’ meta_key that both update when the primary category changes. Another, better, solution for this particular use case would be to use a hidden taxonomy named primary_category and have the categories be the terms.
  • Elasticsearch – If it’s not possible to avoid performing a meta_value query, consider using Elasticsearch instead of MySQL.

One caveat to note is that if you are using Elasticsearch on your site (regardless of if it’s for a particular query or just in general) having multiple distinct meta_keys such as the example of Non-binary situations could potentially cause severe performance problems for Elasticsearch. This is based on the way Elasticsearch will store the data and not how it queries the data and therefore it doesn’t matter if you are using elasticsearch for that particular query or not.

Code review: errors, warnings, and notices

If you have a site that uses VIP’s full review, every line of your code is reviewed by the VIP Team. We don’t do in-depth code reviews to add more time to or delay your launch schedules. We do these lengthy code reviews to help you launch successfully.

The goal of our reviews is to make sure that on launch, your site will be:

  • Secure, because pushing a site live with insecure code presents a liability to you and your whole userbase;
  • Performant, because going live and finding out that your code can’t handle the traffic levels that your site expects puts most of your launch efforts to waste.

We also review for development best practices to make sure that your site will continue to live on without significant maintenance costs or major issues when WordPress is upgraded.

Before submitting any code for review, please be sure to look through our All About Code Review documentation. The following is a checklist of items our VIP engineers look for when reviewing. Please note that this is a living list and we are adding and modifying it as we continue to refine our processes and platform.

On VIP Go, we bucket feedback into three categories:

  • VIP Error – This won’t work or will expose your site to severe performance and security concerns. This category was formerly called a VIP Blocker.
  • VIP Warning – We strongly recommend your team take care of these issues as soon as possible.
  • VIP Notices – Needs to be considered carefully when including them in your VIP theme or plugin.

VIP Errors

Errors are items that if not fixed will likely either not work because of platform incompatability issues or open your site to serious performance and security issues. We strongly recommend they be fixed before being committed to VIP Go. Here’s a partial list of what can be a Error:

Filesystem Operations

On the VIP Platform, web servers run in read-only mode. File operations are only allowed in the /tmp/ directory and media uploads via the VIP Files Service. For more information please consult https://wpvip.com/documentation/vip-go/writing-files-on-vip-go/

Cache constraints

On VIP Go there are many layers of caching. This means that certain operations may not work as expected. You can learn about the Varnish powered Full Page Cache as well as the object and database caching here: https://wpvip.com/documentation/vip-go/caching-on-vip-go/ . We also have documentation on controlling the VIP Go page cache and on caching for WordPress REST API requests.

Validation, Sanitization, and Escaping

Your code works, but is it safe? When writing code for the VIP Go environment, you’ll need to be extra cautious of how you handle data coming into WordPress and how it’s presented to the end user. Please review our documentation on validating, sanitizing, and escaping.

$_GET, $_POST, $_REQUEST, $_SERVER and other data from untrusted sources (including values from the database such as post meta and options) need to be validated and sanitized as early as possible (for example when assigning a $_POST value to a local variable) and escaped as late as possible on output.

Nonces should be used to validate all form submissions.

Capability checks need to validate that users can take the requested actions.

It’s best to do the output escaping as late as possible, ideally as it’s being outputted, as opposed to further up in your script. This way you can always be sure that your data is properly escaped and you don’t need to remember if the variable has been previously validated.

Here are two examples. In order to keep this straight forward, we’ve kept them simple. Imagine a scenario with much more code between the place where $title is defined and where it’s used. The first example is more clear that $title is escaped.

$title = $instance['title'];

// Logic that sets up the widget

echo $before_title . esc_html( $title ) . $after_title;

 

$title = esc_html( $instance['title'] );

// Logic that sets up the widget

echo $before_title . $title . $after_title;

Inserting HTML directly into DOM with JavaScript

To avoid XSS, inserting HTML directly into the document should be avoided.  Instead, DOM nodes should be programmatically created and appended to the DOM.  This means avoiding .html(), .innerHTML(), and other related functions, and instead using .append(), .prepend(),.before(), .after(), and so on.  More information.

Using uncached functions

WordPress provides a variety of functions that interact with the database, not all of which are cacheable. To ensure high performance and stability, please avoid using any of the functions listed on our Uncached Functions list.

VIP Warnings

We strongly recommend your team take care of these issues as soon as possible. In most circumstances code that falls under this category should not be pushed to a production server unless a specific use makes it acceptable.

Whitelisting values for input/output validation

When working with user-submitted data, try where possible to accept data only from a finite list of known and trusted values. For example:

$possible_values = array( 'a', 1, 'good' );
if ( ! in_array( $untrusted, $possible_values, true ) )
die( "Don't do that!" );

Direct Database Queries

Thanks to WordPress’ extensive API, you should almost never need to query database tables directly. Using WordPress APIs rather than rolling your own functions saves you time and assures compatibility with past and future versions of WordPress and PHP. It also makes code reviews go more smoothly because we know we can trust the APIs. More information.

Additionally, direct database queries bypass internal caching. If absolutely necessary, you should evaluate the potential performance of these queries and add caching if needed.  Any queries that would modify database contents may also put the object cache out of sync with the data, causing problems.

Arbitrary JavaScript and CSS stored in options or meta

To limit attack vectors via malicious users or compromised accounts, arbitrary JavaScript cannot be stored in options or meta and then output as-is.

CSS in options or meta should also generally be avoided, but if absolutely necessary, it’s a good idea to properly sanitize it. See art-direction-redux for an example.

Encoding values used when creating a url or passed to add_query_arg()

Add_query_arg() is a really useful function, but it might not work as intended.
The values passed to it are not encoded meaning that passing

$m_yurl = 'admin.php?action=delete&post_id=321';
$my_url = add_query_arg( 'my_arg', 'somevalue&post_id=123', $my_url );

You would expect the url to be:
admin.php?action=delete&post_id=321&somevalue%26post_id%3D123

But in fact it becomes:
admin.php?action=delete&post_id=321&somevalue&post_id=123

Using rawurlencode() on the values passed to it prevents this.

Using rawurlencode() on any variable used as part a the query string, either by using add_query_arg() or directly by string concatenation will also prevent parameter hijacking.

Prefixing functions, constants, classes, and slugs

Per the well-known WordPress adage: prefix all the things.

This applies to things obvious things such as names of function, constants, and classes, and also less obvious ones like post_type and taxonomy slugs, cron event names, etc.

Not checking return values

When defining a variable through a function call, you should always check the function’s return value before calling additional functions or methods using that variable.

function wpcom_vip_meta_desc() {
	$text = wpcom_vip_get_meta_desc();
	if ( ! empty( $text ) ) {
		echo "<meta name='description' content='" . esc_attr( $text ) . "' />";
	}
}

Order By Rand

MySQL queries that use ORDER BY RAND() can be pretty challenging and slow on large datasets. An alternate option can be to retrieve 100 posts and pick one at random.

Manipulating the timezone server-side

Using date_default_timezone_set() and similar isn’t allowed because it conflicts with stats and other systems. Developers instead should use WordPress’s internal timezone support. More information.

Skipping Full Page Caching

On VIP Go, Varnish is used to cache pages at the edges. This improves performance by serving end users a page that comes directly from the nearest datacenter. The functionality is different than on WordPress.com VIP in that GET parameters are always cached, these are cached individually based on the GET parameters and not stripped and the same page used for all requests as it is done on WordPress.com VIP. This does mean that code relying on vary_cache_on_function() will not work as intended. Varnish on VIP Go will respect the Vary header for X-Country-Code and Accept but not Cookie.

Ajax calls on every pageload

Making POST requests to admin-ajax.php on every pageload, or on any pageload without user input, will cause performance issues and need to be rethought. If you have questions, we would be happy to help work through an alternate implementation.  GET requests to admin-ajax.php on VIP Go are cached just like any other GET request.

Front-end db writes

Functions used on the front-end that write to the database are not allowed. This is due to scaling concerns and can easily bring down a site.

*_meta as a hit counters

Please don’t use meta (post_meta, comment_meta, etc.) to track counts of things (e.g. votes, pageviews, etc.). First of all, it won’t work properly because of caching and due to race conditions on high volume sites. It’s also just a recipe for disaster and easy way to break your site. In general you should not try to count/track user events within WordPress; consider using a JavaScript-based solution paired with a dedicated analytics service (such as Google Analytics) instead.

eval() and create_function()

Both these functions can execute arbitrary code that’s constructed at run time, which can be created through difficult-to-follow execution flows. These methods can make your site fragile because unforeseen conditions can cause syntax errors in the executed code, which becomes dynamic. A much better alternative is an Anonymous Function, which is hardcoded into the file and can never change during execution.

If there are no other options than to use this construct, pay special attention not to pass any user provided data into it without properly validating it beforehand.

We strongly recommend using Anonymous Functions, which are much cleaner and more secure.

No LIMIT queries

Using posts_per_page (or numberposts) with the value set to -1 or an unreasonably high number or setting nopaging to true opens up the potential for scaling issues if the query ends up querying thousands of posts.

You should always fetch the lowest number possible that still gives you the number of results you find acceptable. Imagine that your site grows over time to include 10,000 posts. If you specify -1 for posts_per_page, you’ll query with no limit and fetch all 10,000 posts every time the query runs, which is going to destroy your site’s performance. If you know you’ll never have more than 15 posts, then set posts_per_page to 15. If you think you might have more than 15 that you’d want to display but doubt it’d hit more than 100 ever, set the limit to 100. If it gets much higher than that, you might need to rethink the page architecture a bit.

Cron schedules less than 15 minutes or expensive events

Overly frequent cron events (anything less than 15 minutes) can significantly impact the performance of the site, as can cron events that are expensive.

Flash (.swf) files

Flash (.swf) files are not advisable on VIP Go, as they often present a security threat (largely due to poor development practices or due to bugs in the Flash Player) and vulnerabilities are hard to find/detect/secure. Plus, who needs Flash? 🙂

Incorrect licenses

Non-GPL compatible themes or plugins are not allowed on VIP Go. WordPress code is licensed under the GNU Public License v2 (GPL2) and all theme and plugin code needs to be GPL compatible or custom code you’ve written in-house—split or proprietary licenses are not allowed. The reasoning for this is that you, and we, need to have the legal rights to modify the code if something is broken, insecure, or needs optimization.

Ignore development only files

If it’s feasible within your development workflow, we ask that you .gitignore any files that are use exclusively in local development of your theme, these include but are not limited to .svnignore, config.rb, sass-cache, grunt files, PHPUnit tests, etc.

VIP Requirements

Every theme must include a VIP attribution linkwp_head(), and wp_footer() calls.

Expensive 404 Pages

The 404 page needs to be one of the fastest on the site, as it is only cached for 10 seconds. That means that a traffic spike from a broken link can cause performance and even availability problems if the 404 page is doing expensive db lookups.

Commented out code, Debug code or output

VIP themes should not contain debug code and should not output debugging information. That includes the use of functions that provide backtrace information, such as wp_debug_backtrace_summary() or debug_backtrace(). If you’re encountering an issue that can’t be debugged in your development environment, we’ll be glad to help troubleshoot it with you. The use of commented out code should be avoided. Having code that is not ready for production on production is bad practice and could easily lead to mistakes while reviewing (since the commented out code might not of been reviewed and the removing on a comment might slip in accidentally).

Generating email

To prevent issues with spam, abuse or other unwanted communications, your code should not generate, or allow users to generate, email messages to site users or user-supplied email addresses. That includes mailing list functionality, invitations to view or share content, notifications of site activity, or other messages generated in bulk. Where needed, you can integrate third-party SMTP or ESP (Email Service Provider) services that allow sharing of content by email, as long as they don’t depend on the VIP Go infrastructure for message delivery. If you only need to send out a few emails to admins or to a specific email address not in bulk amounts you can use the built in wp_mail() functionality.

Custom wp_mail headers

The PHP Mailer is properly escaping headers for you only in case you’re using appropriate filters inside WordPress. Every time you want to create custom headers using user supplied data (eg.: “FROM” header), make sure you’re using filters provided by WordPress for you. See wp_mail_from() and wp_mail_from_name()

Serializing data

Unserialize has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data.

Including files with untrusted paths or filenames

locate_template(), get_template_part(), and sometimes include() or require() are typically used to include templates. If your template name, file name or path contains any non-static data or can be filtered, you must validate it against directory traversal using validate_file() or by detecting the string “..”

Relative File Includes

Including files with relative paths may lead to unintended results. It’s recommend that all files are included with an absolute path.

You can use one of the many functions available to compose the file path, such as __DIR__ or dirname( __FILE__ ) or plugin_dir_path( __FILE__ ) or get_template_directory()

// Don't do this:
require_once 'file.php';

// Do this instead:
require_once __DIR__ . '/file.php';

Settings alteration

Using ini_set() for alternating PHP settings, as well as other functions with ability to change configuration at runtime of your scripts, such as error_reporting(), is prohibited on the VIP Go platform. Allowed error reporting in production can lead to Full Path Disclosure.

Minified JavaScript files

JavaScript files that are minified should also be committed with changes to their unminified counterparts.  Minified files cannot be read for review, and are much harder to work with when debugging issues.

reCaptcha for Share by Email

To protect against abuse of Jetpack’s share by e-mail feature (aka Sharedaddy) it must be implemented along with reCaptcha. This helps protect against the risk of the WordPress.com network being seen as a source of e-mail spam, which would adversely affect VIP sites. This blog post explains how to implement reCaptcha.

Removing the admin bar

The admin bar is an integral part of the WordPress experience and we highly discourage removing it. It must not be removed for administrator and vip_support roles.

Remote calls

Remote calls such as fetching information from external APIs or resources should rely on the WordPress HTTP API (no cURL) and should be cached. Example of remote calls that should be cached are wp_remote_get(), wp_safe_remote_get(), and wp_oembed_get(). More information.

Using __FILE__ for page registration

When adding menus or registering your plugins, make sure that you use an unique handle or slug other than __FILE__ to ensure that you are not revealing system paths.

Functions that use JOINS, taxonomy relation queries, -cat, -tax queries, subselects or API calls

Close evaluation of the queries is recommended as these can be expensive and lead to performance issues. Queries with known problems when working with large datasets:

  • category__and, tag__and, tax_query with AND
  • category__not_in, tag__not_in, and tax_query with NOT IN
  • tax_query with multiple taxonomies
  • meta_query with a large result set (e.g. looking for only posts with a thumbnail_id meta on a large site, looking for posts with a specific meta value on a key)

Taxonomy queries that do not specify ‘include_children’ => false

Almost all taxonomy queries include 'include_children' => true by default.  This can have a very significant performance impact on code, and in some cases queries will time out.  We recommend 'include_children' => false to be added to all taxonomy queries when possible.

In many instances where all posts in either a parent or child term are wanted, this can be replaced by only querying for the parent term and using a save_post() hook to determine a child term is added, and if so enforce that it’s parent term is also added. A one time WP-CLI command might be needed to ensure previous data integrity.

Custom roles

For best compatibility between environments and for added security, custom user roles and capabilities need to be managed via our helper functions.

Using extract()

extract() should never be used because it is too opaque and difficult to understand how it will behave under a variety of inputs. It makes it too easy to unknowingly introduce new variables into a function’s scope, potentially leading to unintended and difficult to debug conflicts.

Using $_REQUEST

$_REQUEST should never be used because it is hard to track where the data is coming from (was it POST, or GET, or a cookie?), which makes reviewing the code more difficult. Additionally, it makes it easy to introduce sneaky and hard to find bugs, as any of the aforementioned locations can supply the data, which is hard to predict.  Much better to be explicit and use either $_POST or $_GET instead.

Not Using the Settings API

Instead of handling the output of settings pages and storage yourself, use the WordPress Settings API as it handles a lot of the heavy lifting for you including added security.

Make sure to also validate and sanitize submitted values from users using the sanitize callback in the register_setting call.

Using Page Templates instead of Rewrites

A common “hack” in the WordPress community when requiring a custom feature to live at a vanity URL (e.g. /lifestream/) is to use a Page + Page Template. This isn’t ideal for numerous reasons:

  • Requires WordPress to do multiple queries to handle the lookup for the Page and any additional loops your manually run through.
  • Impedes development workflow as it requires the Page to be manually created in each environment and new developer machines as well.

Use wp_parse_url() instead of parse_url()

In PHP versions lower than 5.4.7 schemeless and relative urls would not be parsed correctly by parse_url() we therefore recommend that you use wp_parse_url() for backwards compatibility.

Use wp_safe_redirect() instead of wp_redirect()

Using wp_safe_redirect(), along with the allowed_redirect_hosts filter, can help avoid any chances of malicious redirects within code.  It’s also important to remember to call exit() after a redirect so that no other unwanted code is executed.

Mobile Detection

When targeting mobile visitors, jetpack_is_mobile() should be used instead of wp_is_mobile(). It is more robust and works better with full page caching.

function jetpack_is_mobile( $kind = 'any', $return_matched_agent = false )

Where kind can be:

  • smart
  • dumb
  • any

You can also use:

  • Jetpack_User_Agent_Info::is_ipad()
  • Jetpack_User_Agent_Info::is_tablet()

Views from mobile devices are cached and to make sure things work as expected, please let us know before including any additional server-side logic (PHP) in your code.

These are loaded for you automatically.

Custom API Endpoints without Permissions Callback

For custom API routes that perform writes or read private data, we strongly recommend registering a valid permissions callback for it to ensure that no data is exposed or manipulated.  More information.

Using bloginfo() without escaping

Keeping with the theme of Escaping All the Things, code that uses bloginfo() should use get_bloginfo() instead so that the data can be properly late escaped on output.  Since get_bloginfo() can return multiple types of data, and it can be used in multiple places, it may need escaped with many different functions depending on the context:

echo '<a href="' . esc_url( get_bloginfo( 'url' ) ) . '">' . esc_html( get_bloginfo( 'name' ) ) . '</a>';

echo '<meta property="og:description" content="' . esc_attr( get_bloginfo( 'description' ) ) . '">';

Plugin registration hooks

register_activation_hook() and register_deactivation_hook() are not supported because of the way plugins are loaded on WordPress VIP using wpcom_vip_load_plugin().

VIP Notices

These are items that should be addressed but that code that goes live to production with these will not cause a performance or security problem. They might be best practices that help code maintenance or help keep the error logs clean, etc.

Check for is_array(), !empty() or is_wp_error()

Before using a function that depends on an array, always check to make sure the arguments you are passing are arrays. If not PHP will throw a warning.

For example instead of

$tags = wp_list_pluck( get_the_terms( get_the_ID(), 'post_tag') , 'name');

do:

$tags_array = get_the_terms( get_the_ID(), 'post_tag');
//get_the_terms function returns array of term objects on success, false if there are no terms or the post does not exist, WP_Error on failure. Thus is_array is what we have to check against
if ( is_array( $tags_array ) ) {
    $tags = wp_list_pluck( $tags_array , 'name');
}

Here are some common functions / language constructs that are used without checking the parameters before hand: foreach(), array_merge(), array_filter(), array_map(), array_unique(), wp_list_pluck()
Always check the values passed as parameters or cast the value as an array before using them.

Using in_array() without strict parameter

PHP handles type juggling. This also applies to in_array() meaning that this:

in_array( 0, ['safe_value', 'another string']);

Will return true. See Using == instead of ===.

Inline resources

Inlining images, scripts or styles has been a common work around for performance problems related to HTTP 1.x As more and more of the web is now served via newer protocols (SPDY, HTTP 2.0) these techniques are now detrimental as they cannot be cached and require to be sent every time with the parent resource. Read more about this here.

Using == instead of ===

PHP handles type juggling. Meaning that this:

$var = 0;
if ( $var == 'safe_string' ){
    return true;
}

Will return true. Unless this is the behavior you want you should always use === over ==.

Other interesting things that are equal are:

  • (bool) true == 'string'
  • null == 0
  • 0 == '0SQLinjection'
  • 1 == '1XSS'
  • 0123 == 83 (here 0123 is parsed as an octal representation)
  • 0xF == 15 (here 0xF is parsed as an hexadecimal representation of a number)
  • 01 == '1string'
  • 0 == 'test'
  • 0 == ''

Using output buffering

Output buffering should be used only when truly necessary and should never be used in a context where it is called conditionally or across multiple functions / classes. If used it should always be in the same scope and not with conditionals.

Not defining post_status Or post_type

By default the post_status of a query is set to publish for anonymous users on the front end. It is not set in any WP_ADMIN context including Ajax queries. Queries on the front end for logged in users will also contain an OR statement for private posts created by the logged in user, even if that user is not part of the site. This will reduce the effectiveness of MySQL indexes, specifically the type_status_date index.
The same is true for post_type, if you know that only a certain post_type will match the rest of the query (for example for a taxonomy, meta or just general query) adding the post_type as well as the post_status will help MySQL better utilize the indexes as it’s disposal.

Using closing PHP tags

All PHP files should omit the closing PHP tag to prevent accidental output of whitespace and other characters, which can cause issues such as ‘Headers already sent‘ errors. This is part of the WordPress Coding Standards.

Use wp_json_encode() over json_encode()

wp_json_encode() will take care of making sure the string is valid UTF-8 while the regular function will return false if it encounters invalid UTF-8. It also supports backwards compatibility for versions of PHP that do not accept all the parameters.

Caching large values in options

The options cache on VIP Go works the same as on core WordPress (different from how WordPress.com VIP works). This means that all the options are not autoloaded in a single cache key and that therefore the size of options is not as important as on WordPress.com VIP. That being said, memcache still has a 1MB cache key limit.

switch_to_blog()

For VIP Go Multisite instances, switch_to_blog() only switches the database context. Not the code that would run for that site (for example different filters). It should only be used with extreme caution.


Performance Considerations

We want to make sure that your site runs smoothly and can handle any traffic load. As such, we often make recommendations related to performance, such as: are remote requests fast and cached? Does the site request more data than needed?

Uncached Pageload

Uncached pageloads should be optimized as much as possible. We will load different pages and templates on your theme uncached, looking for slow queries, slow or timed out remote requests, queries that are overly repeated, or function routines that are slow.

Term queries should consider include_children =>false

Previously 'include_children' => false was added to almost every taxonomy query on WordPress.com. When a term query is processed, WordPress first looks to see if the term is shared. When global terms were enabled on WordPress.com (before the 4.4 merge) this would always return true.

If the term is shared it will not include the children term. If the term is not shared, it will include the children.

As of 4.4 Terms have all been split on WordPress.com which means that this now adds 'include_children' => true to almost all taxonomy queries. This can have a very significant performance impact on code that was performant previously. In some cases this change means that queries that used to return almost instantly will time out. We therefore now recommend 'include_children' => false to be added to all taxonomy queries when possible.

In many instances where the wanted behaviour is to grab all posts that are either the parent or a child term this can be replaced by only querying for the parent term and enforcing inside a save_post() hook that if a child term is added that it’s parent term is also added. A one time WP CLI command might be needed to ensure previous data integrity.

User security best practices

We encourage all users on VIP sites to follow best practices when it comes to securing their devices, accounts and access to VIP tools. Two-factor authentication is required for all users with the ability to publish to a VIP site and we also recommend following at least these basic steps:

  1. Set a login password for all user accounts on your computer.
  2. Set a complex (more than 4 character) passcode to unlock your mobile devices. Do not use fingerprints or patterns.
  3. Enable a screen saver that activates after a short period of time and requires a password to turn off.
  4. Use only strong passwords. Never use the same password in more than one place.
  5. Use a password manager such as 1Password or LastPass.
  6. Never put passwords in text documents, Google Docs, intranet pages, post-it notes or other unencrypted forms of storage.
  7. Use two-factor authentication for any services that support it, including WordPress.com accounts, Google apps such as Gmail, Dropbox, Twitter, Facebook, Github, iCloud, LinkedIn, PayPal and others. Do not store 2FA backup codes anywhere online. We strongly recommend using an authenticator app, such as Authy or Google Authenticator, over SMS-based two-factor authentication.
  8. Turn on device locating services such as “Find My Mac” for Apple laptops or “Find My iPhone” for iPhones.
  9. Encrypt your computer’s hard drive, and make sure any backups are encrypted too.
  10. Install and run anti-virus software with the latest virus definitions.
  11. Enable your computer’s firewall.
  12. Ensure that your home and office network routers are running the latest firmware and aren’t using default passwords.
  13. Be suspicious of any unusual requests to share sensitive information, such as usernames, passwords or other personal data. Report any such requests and “phishing” attempts.
  14. If working in public, use a privacy screen to prevent your activity being seen.

If you have any security-related questions about your WordPress.com account, your VIP site or any related service, please contact us via support ticket.

VIP Go local development

Because developing sites for VIP Go is different than developing for WordPress.com hosted VIP sites, you need a different development environment.

Your VIP Go site runs three codebases: WordPress core (tracking the most current version), the VIP Go mu-plugins, and the codebase from your specific site repo. Because of this, a variety of WordPress local development environments can be suitably configured for VIP Go development purposes.

Here we describe using a Varying Vagrant Vagrants (VVV) based local development environment. Other options may include: Chassis, Docker-WP, Laravel Valet, etc.

VVV for VIP Go Development

Note: These instructions assume familiarity with command line tools a macOS, Linux, or similar Operating System.

Prerequisite: all git operations referenced in this guide assume you have an ssh keypair registered with GitHub and are using ssh (vs. https) protocols. Using https protocols may lead to unexpected errors and is not supported.

Step 1: Setting up VVV

The basic instructions for installing VVV are in their documentation. Complete setup per their instructions before continuing below.

Step 2: Adding your site code

Follow the instructions to add a new site, and reprovision vagrant. Then, find and remove the entire wp-content folder at {VVV FOLDER}/www/{site name}/public_html/wp-content. Replace {VVV FOLDER} with the path to your VVV folder (i.e. the folder you installed VVV into), and {site name} with the name of your site.

Git clone your VIP Go site repo in place of it, using the following command. Replace {CLONE URL} with the GitHub clone URL for your VIP Go GitHub repository.

git clone {CLONE URL} {VVV FOLDER}/www/{site name}/public_html/wp-content

Note: VIP Go sites must use the wp-content folder structure from https://github.com/automattic/vip-skeleton. If you do not yet have a VIP Go site repo hosted with us, please use this vip-skeleton repo for the “CLONE URL” above and place your codebase (theme & plugins) within it for testing. Once your VIP Go site repo has been provisioned, you’ll most likely use that repo instead.

Step 3: Adding the VIP Go MU plugins

Vip Go uses a series of platform-specific plugins which are found in the  vip-go-mu-plugins repository on GitHub.  To replicate the VIP Go environment git clone this repo into wp-content/mu-plugins/. You will want to make sure the contents of the repo are in the root of mu-plugins and not a folder such as vip-go-mu-plugins which is the default. You may use the following command replacing {VVV FOLDER} with the path to your VVV folder (i.e., the folder you installed VVV into).

git clone git@github.com:Automattic/vip-go-mu-plugins.git --recursive {VVV FOLDER}/www/{site name}/public_html/wp-content/mu-plugins/

Note: the vip-go-mu-plugins repository is using SSH protocol for submodules, and as GitHub does not allow anonymous SSH connections, you’ll have to set up an SSH key for your GitHub account and use it for interaction with the repository (cloning and submodule updates).

Periodically pull changes down from this repository to ensure you have the latest code… we suggest checking for and pulling changes before the start of development on any given day:

$ cd {VVV FOLDER}/www/{site name}/public_html/wp-content/mu-plugins/
$ git pull origin master
$ git submodule update --init --recursive

Note: Do not commit the mu-plugins/ directory to your VIP Go site’s repository.

The object-cache.php code that’s used in production can be found in drop-ins/object-cache/object-cache.php. Be sure to copy or symlink that to the root of wp-content in order for it to be activated in your local environment. If you have multiple sites using the same repo, be sure to set WP_CACHE_KEY_SALT for each site to avoid cache key collisions.

Step 4: Updating your wp-config file

Add the following code to your wp-config.php just above this line: /* That's all, stop editing! Happy blogging. */:

Include the VIP config file:

if ( file_exists( __DIR__ . '/wp-content/vip-config/vip-config.php' ) ) {
    require_once( __DIR__ . '/wp-content/vip-config/vip-config.php' );
}

Account for file permissions and auto-updates:

The local environment will replicate the file permissions behaviour on VIP Go found in mu-plugins/a8c-files.php only allowing files to be uploaded to tmp and uploads. The following constants should also be added to replicate the file setup on VIP Go. This prevents plugins and themes from being uploaded or updated from WP-Admin matching the Go environment.

define( 'DISALLOW_FILE_EDIT', true );
define( 'DISALLOW_FILE_MODS', true );
define( 'AUTOMATIC_UPDATER_DISABLED', true );

Step 5: Creating an admin user via WP-CLI

SSH into the VVV virtual machine and use WP-CLI to create a new user account with the administrator role. Use the new admin user account to access WP Admin and delete the default admin user account.

wp user create exampleusername user@example.com --role=administrator

This step is a security precaution to avoid default administrator user credentials from a local development environment being present in a production environment. The vip-go-mu-plugins will block login attempts using the admin username and display the notice: Logins are restricted for that user. Please try a different user account.

Step 6: Finishing up

Your development environment is ready to “Go”.  Navigate to site-name.test/wp-admin in your browser and log in and you should see a “VIP” menu in wp-admin.

Using the site’s content in local development

VIP includes VaultPress access with each VIP site, to give clients the ability to self-service the download of hourly SQL database backups. VaultPress is accessible from each site’s WordPress dashboard, under the Jetpack menu. For multisite environments, each site is stored separately in VaultPress. Note that only wp_-prefixed tables are included in these backups.

This backup can then be imported into a local development environment using WP CLI. The jetpack_optionsrow needs to be removed before importing into a local or staging environment, to avoid creating Jetpack conflicts with the production site.

Although VaultPress on VIP Go offers SQL backups only, the media library for each site is redundantly backed up and secure. Please open a ticket to request a copy of the media library.

Performance improvements by removing usage of post__not_in

Using post__not_in means that the query cache hitrate will often be a lot lower. It can also make the query slower if the exclusion list is large. In almost all cases you can gain great speed improvements by requesting more posts and skipping the posts in PHP.

example, instead of:

$other_posts_in_tag = get_posts( array(
	'tag_id' =>  $tag_id,
	'posts_per_page' => $limit,
	'post__not_in'	=> $array_of_post_ids_to_skip,
	'suppress_filters' => false,
));

foreach ( $other_posts_in_tag as $post ){
	//logic goes here;
}

do this:

$other_posts_in_tag = get_posts( array(
	'tag_id' =>  $tag_id,
	'posts_per_page' => $limit + count( $array_of_post_ids_to_skip ),
	'suppress_filters' => false,
));

foreach ( $other_posts_in_tag as $post ){
	if ( in_array( $post, $array_of_post_ids_to_skip ) ){
		continue;
	}
	//logic goes here;
}

Using WP_Rewrite instead of _GET parameters to leverage full page caching

Often in code we’re used to doing something like this http://example.com/my-great-article/?all_pages=1. But this doesn’t play nice with full page caching provided by batcache. To leverage full page cache we need to use the WP_Rewrite API, you can use rewrite endpoints Or use the add_rewrite_rule() and add_rewrite_tag() functions (guide here)
With the help of these functions, you can rewrite your url so that it will now be: http://example.com/my-great-article/all_pages/

But wait — there’s more!

You can even speed up ajax requests with this technique.

You can create http://example.com/ajax/my_frontend_ajax_function/parameter_1/ to make ajax requests that are cached for 5 minutes for all users with batcache. This will make request time go down often 100 folds! Note that the ajax rewrites are to index.php and not to admin-ajax.php. You are creating a new endpoint that will call your PHP function, not rewriting the query to pass to admin-ajax.php

Creating good changesets

Changesets are the heart of any version control system, and making good changesets is vitally important to the maintainability of your code. As all code on WordPress.com VIP is reviewed by a real person, it’s even more important all changesets are well crafted.

Remember always code (and commit) for the maintainer.

A Good Changeset:

Represents one logical change

What comprises a ‘logical change’ is up for interpretation, but only directly related changes are included. Generally, the smaller the changeset, the better.

Good Example: Adding the CSS, JS, HTML, and PHP code for a new UI button.

Bad Example: Adding the new UI button, fixing whitespacing, and tweaking copy in the footer.

Bundles related changes together

It’s much easier to trace refactorings and other changes if related changes are grouped together. Rather than splitting a logical change into many separate commits, related changes should be combined.

Good Example: Refactoring code into a new plugin by moving it to a new file and including that file.

Bad Example: Refactoring code into a new plugin by putting the code removal, addition, and include into separate commits.

Is Atomic

An atomic commit means that the system is always left in a consistent state after the changeset is committed. No one commit would cause the codebase to be in an invalid state. The commit is easily rolled back to a previous valid state, including all related changes, without the need to analyze the potential interdependencies of neighboring commits.

Good Example: Adding a new feature to the homepage by committing the HTML / PHP changes alongside the required CSS / JS changes, so there is never an incomplete state (HTML elements without styling) in the codebase.

Bad Example: Committing the HTML changes and requisite CSS / JS separately. The first commit represents an inconsistent state, as the feature can exist in the DOM without being properly styled.

Is Properly Described

Accurately describing the changes is very important for others (and future you) looking at your code. A good commit message describes the what and why of a change. Please see Writing Good Commit Messages for more information.

Writing good commit messages

Commit messages are one of the most common ways developers communicate with other developers, including our VIP team, so it’s important that your commit message clearly communicate changes with everybody else.

Who are we writing commit messages for?

The audience of a commit message is:

0. People reading the commit timeline.

1. People debugging code.

What is a good commit message?

Having these assumptions in mind:

1. Good commit messages should have a subject line. One sentence briefly describing what the change is, and (if it makes sense) why it was necessary.

A good subject line gives the reader the power to know the gist of the commit without bothering to read the whole commit message.

Example:

Fix stats link on m.example.com

This does not need a high-level why part, because it’s obvious – the links weren’t working.

Example:

Stats Report: clear caches on each post to save memory

Here we need a why part, because if the message was only “clear caches on each post”, the obvious follow-up question is, “Why would you clear cache for each post in a loop?!”.

Whenever the commit is a part of a clearly-defined and named project, prefixing the commit with the project name is also very helpful. It’s not mandatory, because often the project space is vague and the list of committed files reveals similar information.

2. There should be an empty line between the subject line and the rest of the commit message (if any). Whitespace is like bacon for our brains.

3. A good commit message tells why a change was made.

Reasoning why is helpful to both of our audiences. Those following the timeline, can learn a new approach and how to make their code better. Those tracing bugs gain insight for the context of the problem you were trying to solve, and it helps them decide whether the root cause is in the implementation or higher up the chain.

Explaining why is tricky, because it’s often obvious. “I’m fixing it because it’s broken”. “I’m improving this, because it can be better.”

If it’s obvious, go one level deeper. The 5 Whys technique is great. Not only for looking for root causes of problems, but for making sure you are doing what you are doing for the right reasons.

Example:

JSON API: Split class into hierarchy for easier inclusion in ExamplePlugin

Including the old code required a bunch of hacks and compatibility layers.
With the new hierarchy, we can get rid of almost all the hacks and drop the files into ExamplePlugin as is.

Here the commit message very conveniently explains what the downsides were of the old approach and why the new approach is better.

Example:

Remove filtering by ticket

It's not very useful, while it's slow to generate.

The workflow is to usually go to the ticket page and see associated
comments there.

Here the commit message shares a UX decision we made, which is the primary reason of the commit.

5. Most commits fix a problem. In this case a good commit message explains what caused the problem and what its consequences were.

Everybody needs to know what caused a problem in order to avoid causing a similar problem again. Knowing the consequences can explain already noticed erroneous behaviour and can help somebody debugging a problem compare the consequences of this, already fixed problem with the one being debugged.

If possible, avoid the word fix. Almost always there is a more specific verb for your action.

If the problem is caused by a single changeset, a good commit message will mention it.

6. A good commit message explains how it achieves its goal. But only if isn’t obvious.

Most of the time it’s obvious. Only sometimes some high-level algorithm is encoded in the change and it would benefit the reader to know it.

Example:

Add a first pass client stat for bandwidth

Bandwidth is extrapolated from a month sample. From
there we get the average number of bytes per pageview
for each blog. This data is cached in means.json.

All the code for generating the data in means.json is
in the static methods of the class.

Here we explain the algorithm for guessing bandwidth data. It would have been possible to extract this information from the commit, but it would’ve taken a lot of time and energy. Also, by including it in the commit message we imply that it’s important for you to know that.

7. If the subject line of a commit message contains the word and or in other way lists more than one item, the commit is probably too large. Split it.

Make your commits as small as possible. If you notice a coding style problem while fixing a bug, make a note and fix it after you fix the bug. If you are fixing a bug and you notice another bug, make a note and fix the second bug in another commit.

The same is especially true for white space changes to existing code. White spaces changes should be a separate commit.

8. A good commit message should not depend on the code to explain what it does or why it does it.

Two notes here:

This doesn’t mean we should tell what each line of code does. It means that we should convey all the non-trivial information in the code to the commit message.

This doesn’t mean we whouldn’t include any of this information in the code. Knowing why a function exists, what it does, or what algorithm does it use can often be a useful comment.

9. It’s perfectly OK to spend more time crafting your commit message than writing the code for your commit.

10. It’s perfectly OK for your commit message to be longer than your commit.

11. A good commit message gives props and references relevant tickets.

12. Common sense always overrules what a good commit message thinks it should be.

Other perspectives

Here’s another excellent post that explains how to approach a good commit message: http://robots.thoughtbot.com/5-useful-tips-for-a-better-commit-message

The Code: guidelines for VIP developers

307_6563_ch-929

At WordPress.com VIP, we feel very privileged to work with some of the best developers on some of the world’s biggest sites. It’s a small community of smart people who get to build some amazing technology.

As a developer working on WordPress.com VIP, I will:

  • Never stop learning.
  • Not be afraid to ask questions.
  • Be open to feedback, constructive criticism, and collaborative discussion.
  • Be proactive in finding solutions, and not wait for someone else to resolve it for me.
  • Test and review my code before submitting for peer review.
  • Escape, sanitize, and validate all the things.
  • Be kind, courteous, and helpful to my fellow developers.

Two helpful links to get you started:

Writing custom WP-CLI commands

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.

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.

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-&amp;amp;amp;gt;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!

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.