Back to Blog
Development Patterns

WordPress VIP Code Review and Coding Standards: Passing Automated Scans and Building VIP-Compliant Code

Rachel Torres
45 min read

Why VIP Standards Exist and Why They Matter Beyond VIP

WordPress VIP is the enterprise hosting tier operated by Automattic. It powers some of the largest WordPress installations in the world: Time, TechCrunch, Salesforce, Facebook (for their newsroom), and hundreds of media companies processing billions of page views per month. The engineering constraints on VIP are not arbitrary. Every rule in the VIP coding standards exists because someone, somewhere, shipped code that took down a site serving millions of readers.

The VIP coding standards enforce patterns that prevent catastrophic failures at scale. Direct database queries that bypass caching can saturate a database cluster. Uncached remote HTTP requests can create cascading timeouts. File system writes can fail silently on a distributed infrastructure. These are not theoretical concerns. They are incident reports turned into static analysis rules.

Even if you never deploy to VIP, adopting these standards produces measurably better WordPress code. The patterns they enforce, proper caching, safe database access, strict input sanitization, translate directly to better performance and security on any hosting environment. Think of VIP standards as a strict teacher: the lessons are hard, but they make you a better developer.

This article walks through the entire VIP compliance workflow. We will cover the rulesets themselves, local tooling setup, every major category of PHPCS violation you will encounter, the automated code review bot, performance patterns, security patterns, plugin restrictions, pre-commit automation, and team workflow integration. Every code example is drawn from real review feedback I have seen on VIP projects over the past several years.

The Two VIP Rulesets: WordPressVIPMinimum and WordPress-VIP-Go

VIP maintains two primary PHPCS rulesets, and understanding the distinction between them is essential before you write a single line of code.

WordPressVIPMinimum

This is the baseline ruleset. It flags the most dangerous patterns: code that will definitely cause problems on VIP infrastructure. The rules here are non-negotiable. If your code triggers a WordPressVIPMinimum error, it will not pass code review. Period.

The ruleset covers:

  • Direct database queries using $wpdb methods without caching
  • Filesystem operations using PHP native functions like file_get_contents() and file_put_contents()
  • Remote HTTP requests not using the WordPress HTTP API
  • Use of eval(), create_function(), and other code execution functions
  • Sessions and session-like patterns
  • Output buffering in certain contexts
  • Unescaped output
  • Missing nonce verification

The PHPCS sniff names follow a consistent naming convention. For example, WordPressVIPMinimum.Functions.RestrictedFunctions catches banned PHP functions, while WordPressVIPMinimum.Performance.NoPaging detects queries that disable pagination limits.

WordPress-VIP-Go

This is the broader ruleset that includes WordPressVIPMinimum plus additional rules from the WordPress Coding Standards (WPCS) project. It enforces code style, documentation standards, and additional security and performance patterns. On VIP Go (the current VIP platform), this is the ruleset the automated bot uses during pull request review.

WordPress-VIP-Go includes rules from these component standards:

  • WordPress-Core for code style and formatting
  • WordPress-Extra for additional best practices
  • WordPress-Docs for inline documentation
  • WordPressVIPMinimum for VIP-specific restrictions

When you run PHPCS locally, you should test against WordPress-VIP-Go to get the full picture. If you only test against WordPressVIPMinimum, you will miss style and documentation issues that the bot will flag during review.

Setting Up Local PHPCS with VIP Standards

Running PHPCS locally before pushing code is not optional. It is the single most effective way to avoid review delays. Here is the complete setup process.

Installation via Composer

The recommended approach is a per-project Composer installation. This ensures every developer on your team uses the same PHPCS version and the same rulesets.

composer require --dev squizlabs/php_codesniffer
composer require --dev automattic/vipwpcs
composer require --dev phpcompatibility/phpcompatibility-wp
composer require --dev dealerdirect/phpcodesniffer-composer-installer

The phpcodesniffer-composer-installer package is critical. It automatically registers the installed standards with PHPCS so you do not need to manually set installed_paths. Without it, PHPCS will not find the VIP rulesets.

Verify the installation by listing available standards:

vendor/bin/phpcs -i

You should see output that includes WordPressVIPMinimum, WordPress-VIP-Go, WordPress-Core, WordPress-Extra, and WordPress-Docs.

Configuration with phpcs.xml

Create a phpcs.xml or .phpcs.xml.dist file in your project root. This file defines the ruleset, file paths to scan, and any rule customizations.

<?xml version="1.0"?>
<ruleset name="My VIP Project">
    <description>Custom ruleset for our VIP project.</description>

    <rule ref="WordPress-VIP-Go" />

    <!-- Set minimum PHP version for PHPCompatibility -->
    <config name="testVersion" value="8.0-" />

    <!-- Scan only our custom code -->
    <file>./themes/my-theme/</file>
    <file>./plugins/my-plugin/</file>

    <!-- Exclude third-party code -->
    <exclude-pattern>*/vendor/*</exclude-pattern>
    <exclude-pattern>*/node_modules/*</exclude-pattern>
    <exclude-pattern>*/tests/*</exclude-pattern>

    <!-- Set text domain for i18n checks -->
    <rule ref="WordPress.WP.I18n">
        <properties>
            <property name="text_domain" type="array">
                <element value="my-theme" />
            </property>
        </properties>
    </rule>

    <!-- Allow short array syntax -->
    <rule ref="Universal.Arrays.DisallowShortArraySyntax">
        <severity>0</severity>
    </rule>
</ruleset>

With this configuration file in place, you can run PHPCS without specifying arguments:

vendor/bin/phpcs

PHPCS will automatically discover the configuration file and apply the rules to the specified paths.

IDE Integration

Every major PHP IDE supports PHPCS integration. In PHPStorm, navigate to Settings, then PHP, then Quality Tools, then PHP_CodeSniffer. Point the executable path to vendor/bin/phpcs in your project. Then under Editor, then Inspections, enable PHP_CodeSniffer validation and select the WordPress-VIP-Go standard. Violations will appear as inline warnings and errors as you type.

For VS Code, install the wongjn.vscode-phpcs extension. Configure it in your workspace settings:

{
    "phpcs.executablePath": "./vendor/bin/phpcs",
    "phpcs.standard": "WordPress-VIP-Go"
}

Real-time feedback in your editor eliminates the cycle of push, wait for bot, fix, push again. You see violations as you write them.

Common PHPCS Failures: Direct Database Queries

The most frequent category of VIP PHPCS failures involves direct database access through the $wpdb global. VIP infrastructure uses a sophisticated object cache (typically Memcached or Redis) to reduce database load. Direct queries bypass this cache layer entirely.

The Violation: Uncached Direct Queries

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.dbDelta_dbDelta
dbDelta( $sql );

// VIOLATION: WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users
global $wpdb;
$users = $wpdb->get_results( "SELECT * FROM $wpdb->users WHERE user_status = 0" );

// VIOLATION: WordPressVIPMinimum.Performance.NoPaging.nopaging
$posts = get_posts( array(
    'nopaging' => true,
) );

// VIOLATION: Direct database query without caching
global $wpdb;
$count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = 'my_key'" );

Each of these triggers a different sniff, but they all share the same root problem: unbounded or uncached database access.

The Fix: Cached Alternatives and Proper API Usage

For user queries, use WP_User_Query or get_users(), which integrate with the object cache:

// COMPLIANT: Uses WP_User_Query which integrates with object cache
$user_query = new WP_User_Query( array(
    'meta_key'   => 'user_status',
    'meta_value' => '0',
    'fields'     => 'all',
) );
$users = $user_query->get_results();

For post queries, always use WP_Query or get_posts() with pagination:

// COMPLIANT: Paginated query using WP_Query
$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 100,
    'paged'          => $current_page,
    'no_found_rows'  => true, // Skip SQL_CALC_FOUND_ROWS when you don't need total count
) );

When you absolutely must run a direct query (and there are legitimate cases), wrap it with wp_cache_get() and wp_cache_set():

// COMPLIANT: Direct query with proper caching
global $wpdb;

$cache_key = 'my_custom_count_' . md5( 'my_key' );
$count     = wp_cache_get( $cache_key, 'my-plugin' );

if ( false === $count ) {
    $count = $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
            'my_key'
        )
    );
    wp_cache_set( $cache_key, $count, 'my-plugin', HOUR_IN_SECONDS );
}

return (int) $count;

Notice three things in that compliant example. First, we use $wpdb->prepare() for parameterized queries, which addresses the SQL injection sniff (WordPress.DB.PreparedSQL). Second, we wrap the result in wp_cache_get()/wp_cache_set() to satisfy the caching requirement. Third, we cast the return value to (int) because data from the database should never be trusted implicitly.

The prepare() Requirement

The sniff WordPress.DB.PreparedSQL.NotPrepared flags any $wpdb method call where the SQL string is not run through $wpdb->prepare(). This is one of the strictest rules, and it catches a surprising number of cases.

// VIOLATION: WordPress.DB.PreparedSQL.NotPrepared
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE post_type = '{$post_type}'" );

// COMPLIANT: Using $wpdb->prepare()
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_type = %s",
        $post_type
    )
);

Even when your query has no dynamic values, the sniff may still flag it if you are concatenating table names or other variables into the SQL string. In those cases, use $wpdb->prepare() with the table name as part of the format string, or add an appropriate phpcs:ignore comment with a justification.

Common PHPCS Failures: File Operations

VIP runs on a distributed infrastructure where the filesystem is not guaranteed to behave like a traditional single-server setup. The local filesystem may be read-only, ephemeral, or shared across containers. PHP’s native file functions are therefore restricted.

The Violations

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents
file_put_contents( '/tmp/export.csv', $csv_data );

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite
$handle = fopen( $path, 'w' );
fwrite( $handle, $data );
fclose( $handle );

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_get_contents
$content = file_get_contents( $url );

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink
unlink( $temp_file );

The Compliant Alternatives

For reading remote URLs, use the WordPress HTTP API:

// COMPLIANT: Using wp_remote_get() for remote content
$response = wp_remote_get( $url, array(
    'timeout' => 15,
) );

if ( is_wp_error( $response ) ) {
    // Handle the error
    return false;
}

$body        = wp_remote_retrieve_body( $response );
$status_code = wp_remote_retrieve_response_code( $response );

if ( 200 !== $status_code ) {
    // Handle non-200 response
    return false;
}

For file writes, use the WordPress Filesystem API:

// COMPLIANT: Using WP_Filesystem for file operations
global $wp_filesystem;

if ( ! function_exists( 'WP_Filesystem' ) ) {
    require_once ABSPATH . 'wp-admin/includes/file.php';
}

WP_Filesystem();

if ( $wp_filesystem ) {
    $wp_filesystem->put_contents(
        $file_path,
        $data,
        FS_CHMOD_FILE
    );
}

For temporary files on VIP specifically, use the get_temp_dir() function and the wp_tempnam() function rather than hardcoding paths:

// COMPLIANT: Using WordPress temp file functions
$temp_file = wp_tempnam( 'my-prefix-' );
// Use $temp_file, then clean up
wp_delete_file( $temp_file );

Common PHPCS Failures: Remote HTTP Requests

Using PHP’s native HTTP functions (curl_*, file_get_contents() with URLs) is prohibited on VIP because these functions bypass VIP’s request monitoring, timeout management, and caching infrastructure.

The Violations

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get
// (This fires when wp_remote_get is used without caching the result)
$data = wp_remote_get( $api_url );
// Using $data directly in every page load without caching

// VIOLATION: WordPressVIPMinimum.Functions.RestrictedFunctions.curl_curl_init
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $url );
$result = curl_exec( $ch );
curl_close( $ch );

The Compliant Pattern

All remote requests on VIP should go through wp_remote_get(), wp_remote_post(), or wp_safe_remote_get(), and the results should be cached:

// COMPLIANT: Cached remote request
function get_api_data( $endpoint ) {
    $cache_key = 'api_data_' . md5( $endpoint );
    $cached    = wp_cache_get( $cache_key, 'my-plugin' );

    if ( false !== $cached ) {
        return $cached;
    }

    $response = wp_safe_remote_get( $endpoint, array(
        'timeout'    => 10,
        'user-agent' => 'MyPlugin/1.0',
    ) );

    if ( is_wp_error( $response ) ) {
        // Return cached stale data if available, or false
        return false;
    }

    $status = wp_remote_retrieve_response_code( $response );
    if ( 200 !== $status ) {
        return false;
    }

    $body = wp_remote_retrieve_body( $response );
    $data = json_decode( $body, true );

    if ( json_last_error() !== JSON_ERROR_NONE ) {
        return false;
    }

    wp_cache_set( $cache_key, $data, 'my-plugin', 15 * MINUTE_IN_SECONDS );

    return $data;
}

Use wp_safe_remote_get() instead of wp_remote_get() when the URL comes from user input. The “safe” variant validates that the URL points to an external resource and blocks requests to internal/private IP ranges, preventing SSRF attacks.

The VIP Code Analysis Bot

When you open a pull request on a VIP repository, the VIP Code Analysis Bot automatically runs PHPCS against your changes. Understanding how this bot works and how to interpret its output saves significant back-and-forth during review.

How the Bot Works

The bot runs on every pull request and every subsequent push to that PR’s branch. It scans only the files changed in the PR, not the entire codebase. This is an important distinction: legacy code that predates VIP standards will not trigger failures unless you modify those files.

The bot posts its findings as inline comments on the PR diff. Each comment includes the PHPCS rule name, the severity level (error or warning), and a brief description of the issue. Errors must be resolved before the PR can be merged. Warnings should be addressed but may be acceptable in certain cases with justification.

The bot uses the WordPress-VIP-Go ruleset, which means it checks style, documentation, security, and performance rules in addition to the VIP-specific restrictions.

Interpreting Bot Feedback

Bot comments follow a consistent format:

⚠ WARNING: Processing form data without nonce verification.
  Rule: WordPress.Security.NonceVerification.Missing
  File: themes/my-theme/includes/ajax-handler.php:42

The rule name tells you exactly which sniff triggered. You can look up the rule in the PHPCS source code or the VIP documentation to understand the rationale. The file path and line number point you directly to the offending code.

Common bot feedback patterns include:

Errors (must fix):

  • WordPressVIPMinimum.Functions.RestrictedFunctions: You used a banned function
  • WordPress.DB.PreparedSQL.NotPrepared: SQL query without $wpdb->prepare()
  • WordPress.Security.EscapeOutput.OutputNotEscaped: Unescaped output in HTML context
  • WordPressVIPMinimum.Security.ProperEscapingFunction: Wrong escaping function for the context

Warnings (should fix):

  • WordPress.Security.NonceVerification.Missing: Form processing without nonce check
  • WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude: Using post__not_in which is slow at scale
  • WordPress.WP.AlternativeFunctions: Using a PHP function where a WP wrapper exists

Using phpcs:ignore Comments

Sometimes you need to suppress a specific rule for a specific line. The VIP bot respects phpcs:ignore comments, but VIP reviewers will scrutinize them. Every ignore comment should include a justification:

// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_get_contents -- Reading a local file bundled with the plugin, not a remote URL.
$template = file_get_contents( plugin_dir_path( __FILE__ ) . 'templates/email.html' );

Overuse of phpcs:ignore is a red flag for VIP reviewers. If you find yourself suppressing the same rule across many files, that is a sign you need to refactor your approach rather than silence the linter.

The Human Review Layer

The bot is the first pass, but VIP also assigns human reviewers to your code. These reviewers are experienced WordPress engineers who evaluate architectural decisions, caching strategies, query efficiency, and overall code quality. The bot catches syntax-level violations; human reviewers catch design-level problems.

A common scenario: your code passes all PHPCS rules but makes 50 individual get_post_meta() calls inside a loop. PHPCS will not flag this because each individual call is valid. A human reviewer will flag it because it creates N+1 query patterns that degrade performance under load.

Performance Patterns on VIP

VIP infrastructure includes a persistent object cache (typically Memcached), a page cache (typically Varnish or similar), and a CDN. Writing performant code on VIP means leveraging these caching layers rather than fighting against them.

get_posts() vs WP_Query

Both get_posts() and WP_Query execute database queries. The difference is in their defaults and behavior.

get_posts() sets suppress_filters to true by default. This means it bypasses any filters that plugins might apply to queries, including caching filters. On VIP, where advanced object caching is often implemented via query filters, this can mean your get_posts() call bypasses the cache entirely.

// SUBOPTIMAL on VIP: suppress_filters defaults to true
$posts = get_posts( array(
    'post_type'      => 'product',
    'posts_per_page' => 10,
) );

// BETTER on VIP: Explicit WP_Query for full filter support
$query = new WP_Query( array(
    'post_type'      => 'product',
    'posts_per_page' => 10,
) );
$posts = $query->posts;

If you use get_posts(), explicitly set suppress_filters to false:

// ACCEPTABLE: get_posts with suppress_filters disabled
$posts = get_posts( array(
    'post_type'        => 'product',
    'posts_per_page'   => 10,
    'suppress_filters' => false,
) );

Batching Large Operations

Any operation that touches more than a few hundred records must be batched. VIP will reject code that attempts to load thousands of posts in a single query. The posts_per_page parameter should never exceed 100 without strong justification, and using nopaging => true or posts_per_page => -1 is prohibited.

// VIOLATION: WordPressVIPMinimum.Performance.NoPaging.nopaging
$all_posts = get_posts( array(
    'post_type'      => 'post',
    'posts_per_page' => -1, // Loads ALL posts into memory
) );

// COMPLIANT: Batched processing
function process_all_posts_batched( $post_type = 'post', $batch_size = 50 ) {
    $page = 1;

    do {
        $query = new WP_Query( array(
            'post_type'      => $post_type,
            'posts_per_page' => $batch_size,
            'paged'          => $page,
            'no_found_rows'  => false, // Need this for max_num_pages
            'fields'         => 'ids', // Only fetch IDs if you don't need full objects
        ) );

        if ( ! $query->have_posts() ) {
            break;
        }

        foreach ( $query->posts as $post_id ) {
            // Process each post
            process_single_post( $post_id );
        }

        // Free memory
        wp_reset_postdata();

        $page++;

    } while ( $page <= $query->max_num_pages );
}

The no_found_rows Optimization

By default, WP_Query runs a SQL_CALC_FOUND_ROWS query to determine the total number of matching posts. This is necessary for pagination (to know the total number of pages), but it adds overhead to every query.

When you do not need pagination information, set no_found_rows to true:

// OPTIMIZED: Skip counting total rows when you don't need pagination
$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 5,
    'no_found_rows'  => true, // Skips SQL_CALC_FOUND_ROWS
) );

On tables with millions of rows, this single parameter can cut query time significantly. VIP reviewers will often suggest adding this to queries that power widgets, sidebars, or other non-paginated displays.

Avoiding post__not_in

The post__not_in parameter generates a NOT IN SQL clause, which performs poorly on large datasets because MySQL cannot use an index efficiently for exclusion queries.

// SLOW at scale: WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 10,
    'post__not_in'   => $excluded_ids, // Can be very slow with large arrays
) );

// BETTER: Fetch extra posts and filter in PHP
$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 10 + count( $excluded_ids ),
) );

$filtered_posts = array();
foreach ( $query->posts as $post ) {
    if ( ! in_array( $post->ID, $excluded_ids, true ) ) {
        $filtered_posts[] = $post;
    }
    if ( count( $filtered_posts ) >= 10 ) {
        break;
    }
}

This pattern fetches slightly more posts than needed and filters in PHP, which is far more efficient than forcing MySQL to evaluate a NOT IN clause against a large set.

Taxonomy Queries and meta_query Performance

On VIP, taxonomy queries are generally well-cached and performant. Meta queries, however, can be problematic at scale. The wp_postmeta table lacks efficient indexing for arbitrary meta_key/meta_value combinations.

For frequently queried meta values, consider using a taxonomy instead:

// SLOW at scale: meta_query on a high-traffic query
$query = new WP_Query( array(
    'post_type'  => 'product',
    'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
        array(
            'key'   => 'product_color',
            'value' => 'red',
        ),
    ),
) );

// BETTER: Use a custom taxonomy for filterable attributes
// Register 'product_color' as a taxonomy, then query:
$query = new WP_Query( array(
    'post_type' => 'product',
    'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
        array(
            'taxonomy' => 'product_color',
            'field'    => 'slug',
            'terms'    => 'red',
        ),
    ),
) );

VIP has Elasticsearch (or a similar search index) available through the es-wp-query drop-in or the VIP Search integration. For complex filtering queries, offloading to Elasticsearch rather than running expensive MySQL meta queries is the recommended approach.

Security Patterns on VIP

VIP’s security requirements go beyond standard WordPress best practices. The rules enforce defense-in-depth: even if one layer fails, other layers prevent exploitation.

Nonce Verification

Every form submission and AJAX request must include a nonce, and the handler must verify it before processing any data. The sniff WordPress.Security.NonceVerification.Missing catches cases where $_POST, $_GET, or $_REQUEST data is accessed without a preceding nonce check.

// VIOLATION: WordPress.Security.NonceVerification.Missing
function handle_form_submission() {
    if ( isset( $_POST['email'] ) ) {
        $email = sanitize_email( $_POST['email'] );
        update_user_meta( get_current_user_id(), 'contact_email', $email );
    }
}

// COMPLIANT: Nonce verification before processing
function handle_form_submission() {
    if ( ! isset( $_POST['my_form_nonce'] ) ||
         ! wp_verify_nonce(
             sanitize_text_field( wp_unslash( $_POST['my_form_nonce'] ) ),
             'my_form_action'
         )
    ) {
        wp_die( 'Security check failed.' );
    }

    if ( isset( $_POST['email'] ) ) {
        $email = sanitize_email( wp_unslash( $_POST['email'] ) );
        update_user_meta( get_current_user_id(), 'contact_email', $email );
    }
}

Note the use of wp_unslash() before sanitization. WordPress adds slashes to all superglobal data via wp_magic_quotes(). You must unslash before sanitizing, or the sanitization functions may produce incorrect results for values containing quotes or backslashes.

Input Sanitization

VIP requires sanitization of all input data using the appropriate WordPress sanitization function. The key word is “appropriate.” Using sanitize_text_field() on HTML content strips the HTML. Using wp_kses_post() on a plain text field allows HTML that should not be there.

// Match sanitization function to data type
$title       = sanitize_text_field( wp_unslash( $_POST['title'] ) );
$email       = sanitize_email( wp_unslash( $_POST['email'] ) );
$url         = esc_url_raw( wp_unslash( $_POST['website'] ) );
$html_body   = wp_kses_post( wp_unslash( $_POST['body'] ) );
$integer_val = absint( $_POST['quantity'] );
$file_name   = sanitize_file_name( wp_unslash( $_POST['filename'] ) );
$textarea    = sanitize_textarea_field( wp_unslash( $_POST['description'] ) );
$key         = sanitize_key( wp_unslash( $_POST['option_name'] ) );

The sniff WordPress.Security.ValidatedSanitizedInput.InputNotSanitized catches any access to superglobal data that is not immediately passed through a sanitization function. The sniff WordPress.Security.ValidatedSanitizedInput.MissingUnslash catches missing wp_unslash() calls.

Output Escaping

Every piece of dynamic data rendered in HTML must be escaped at the point of output. Not before. Not in a variable assignment. At the exact point where it enters the HTML context.

// VIOLATION: WordPress.Security.EscapeOutput.OutputNotEscaped
echo $user_name;
echo '<a href="' . $url . '">' . $title . '</a>';

// COMPLIANT: Escaped at point of output
echo esc_html( $user_name );
echo '<a href="' . esc_url( $url ) . '">' . esc_html( $title ) . '</a>';

The escaping function must match the context:

  • esc_html() for content displayed as text in HTML
  • esc_attr() for values inside HTML attributes
  • esc_url() for URLs in href, src, and similar attributes
  • esc_js() for inline JavaScript strings
  • wp_kses_post() for trusted HTML that should retain permitted tags
  • wp_kses() for HTML with a custom set of allowed tags

The sniff WordPressVIPMinimum.Security.ProperEscapingFunction catches cases where the wrong escaping function is used for the context. For example, using esc_html() inside an attribute context where esc_attr() is required.

Late Escaping Principle

VIP enforces “late escaping,” meaning you escape data as close to the output point as possible. Escaping a value when it is first retrieved and then echoing the pre-escaped variable later is not acceptable, because code between the escaping point and the output point could modify the value.

// NOT ACCEPTABLE on VIP: Early escaping
$safe_title = esc_html( $title );
// ... 50 lines of code that might modify $safe_title ...
echo $safe_title; // PHPCS flags this because it cannot verify $safe_title is still escaped

// CORRECT: Late escaping at the output point
echo esc_html( $title );

For complex HTML templates, printf() and sprintf() help keep escaping close to output:

// COMPLIANT: Using printf with escaping
printf(
    '<div class="%1$s"><h2>%2$s</h2><p>%3$s</p><a href="%4$s">%5$s</a></div>',
    esc_attr( $css_class ),
    esc_html( $heading ),
    wp_kses_post( $description ),
    esc_url( $link ),
    esc_html( $link_text )
);

Handling Incompatible Plugins on VIP

VIP maintains a list of plugins that are not allowed on the platform. Some are blocked entirely; others are available but require review or have approved alternatives. Understanding what is blocked and why helps you make better architectural decisions.

Why Plugins Get Blocked

Plugins are blocked on VIP for several reasons:

Filesystem writes: Plugins that write to the filesystem (caching plugins that generate static files, code editors, file managers) are incompatible with VIP’s read-only or distributed filesystem. W3 Total Cache, WP Super Cache, and similar plugins fall into this category because VIP handles caching at the infrastructure level.

Direct database operations: Plugins that run arbitrary SQL queries, create custom tables without review, or modify core tables outside of WordPress APIs can conflict with VIP’s database management. Some backup plugins and database optimization plugins are restricted for this reason.

Security risks: Plugins that allow arbitrary code execution, file uploads without proper validation, or expose administrative functionality without proper access control are blocked. This includes most file manager plugins and some theme builders with PHP code execution features.

Performance impact: Plugins that generate excessive database queries, make uncached external HTTP requests on every page load, or load large amounts of JavaScript/CSS without proper enqueuing are flagged during review and may be rejected.

Common Blocked Categories and Alternatives

Caching plugins (W3 Total Cache, WP Super Cache, WP Rocket): VIP provides page caching and object caching at the infrastructure level. You do not need a caching plugin. Instead, use wp_cache_get()/wp_cache_set() in your code and rely on VIP’s built-in caching layers.

Backup plugins (UpdraftPlus, BackWPup): VIP handles backups automatically. Code is managed via Git, and database backups are handled by VIP’s operations team.

Security plugins (Wordfence, Sucuri): VIP provides WAF (Web Application Firewall), DDoS protection, and malware scanning at the infrastructure level. Installing an additional security plugin adds overhead without providing meaningful additional protection on VIP’s managed infrastructure.

SEO plugins: Yoast SEO and similar plugins are generally allowed on VIP, but the VIP team recommends reviewing the configuration to disable features that generate heavy database queries (like the link counter feature in older Yoast versions).

Checking Plugin Compatibility

Before committing to a plugin for a VIP project, check the VIP documentation for its status. VIP maintains an online lobby system where you can look up specific plugins. You can also reach out to VIP support before beginning development to confirm a plugin’s compatibility status.

When evaluating a third-party plugin yourself, check for these red flags:

// Red flags in plugin code that indicate VIP incompatibility:

// 1. Direct file writes
file_put_contents( $path, $data );

// 2. Creating custom database tables
$wpdb->query( "CREATE TABLE IF NOT EXISTS ..." );

// 3. Using PHP sessions
session_start();
$_SESSION['data'] = $value;

// 4. Executing arbitrary code
eval( $user_code );
call_user_func( $_POST['callback'] );

// 5. Using WP_CONTENT_DIR for writable storage
$log = fopen( WP_CONTENT_DIR . '/debug.log', 'a' );

// 6. Modifying .htaccess
insert_with_markers( get_home_path() . '.htaccess', 'MyPlugin', $rules );

If a plugin you need uses any of these patterns, you have three options: find an alternative plugin, fork the plugin and refactor the problematic code, or implement the functionality yourself using VIP-compliant patterns.

Pre-Commit Hooks for Catching Violations Before Push

Running PHPCS manually is fine. Running it automatically before every commit is better. A pre-commit hook ensures that non-compliant code never enters your repository’s history.

Basic Pre-Commit Hook

Create a file at .git/hooks/pre-commit (or use a tool like Husky for team-wide hooks):

#!/bin/bash

# Get list of PHP files staged for commit
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.php$')

if [ -z "$STAGED_FILES" ]; then
    exit 0
fi

# Run PHPCS on staged files only
echo "Running PHPCS on staged PHP files..."
vendor/bin/phpcs --standard=WordPress-VIP-Go $STAGED_FILES

if [ $? -ne 0 ]; then
    echo ""
    echo "PHPCS found violations. Fix them before committing."
    echo "To bypass this hook (not recommended), use: git commit --no-verify"
    exit 1
fi

echo "PHPCS passed. Proceeding with commit."
exit 0

Make it executable:

chmod +x .git/hooks/pre-commit

Advanced Hook with Stash Support

The basic hook has a flaw: it runs PHPCS on the current working copy of staged files, not on the staged version itself. If you have staged some changes but not others, the hook might pass on code that includes unstaged modifications. A more precise approach stashes unstaged changes first:

#!/bin/bash

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.php$')

if [ -z "$STAGED_FILES" ]; then
    exit 0
fi

# Stash unstaged changes
git stash push --keep-index --quiet

echo "Running PHPCS..."
vendor/bin/phpcs --standard=WordPress-VIP-Go --report=summary --report=full $STAGED_FILES
RESULT=$?

# Restore unstaged changes
git stash pop --quiet

if [ $RESULT -ne 0 ]; then
    echo ""
    echo "Commit blocked: PHPCS violations found."
    exit 1
fi

exit 0

Using GrumPHP for Team-Wide Automation

For team projects, grumphp provides a framework for managing Git hooks via Composer:

composer require --dev phpro/grumphp

Create a grumphp.yml configuration:

grumphp:
    tasks:
        phpcs:
            standard: WordPress-VIP-Go
            triggered_by: [php]
            whitelist_patterns:
                - /^themes//
                - /^plugins//
            ignore_patterns:
                - /vendor//
                - /node_modules//

GrumPHP automatically installs its Git hooks when you run composer install, so every developer on the team gets the hooks without manual setup. It also supports additional tasks like PHPStan, PHPUnit, and commit message validation.

Lint-Staged with Husky (for mixed JS/PHP projects)

If your project also includes JavaScript, you might already use Husky for JS linting. You can add PHPCS to the same workflow:

{
    "husky": {
        "hooks": {
            "pre-commit": "lint-staged"
        }
    },
    "lint-staged": {
        "*.php": [
            "vendor/bin/phpcs --standard=WordPress-VIP-Go"
        ],
        "*.js": [
            "eslint --fix",
            "git add"
        ]
    }
}

This runs PHPCS only on staged PHP files and ESLint on staged JavaScript files, keeping commit times fast by avoiding full-project scans.

Building a Team Workflow Around VIP Code Quality

Tools and rules are only half the equation. The other half is how your team works with them day-to-day. A workflow that makes compliance easy and violations hard produces better code than one that relies on individual discipline alone.

Onboarding New Developers

Every new developer joining a VIP project should complete these steps before writing code:

  1. Install the local development environment with PHPCS pre-configured (using Composer and the project’s phpcs.xml)
  2. Configure their IDE with real-time PHPCS feedback
  3. Run vendor/bin/phpcs on an existing file to verify their setup produces the expected warnings and errors
  4. Review the VIP documentation on coding standards, paying particular attention to the “what not to do” examples
  5. Submit a small PR to familiarize themselves with the bot’s feedback cycle

Creating a documented onboarding checklist in your repository (a CONTRIBUTING.md file) ensures this process is consistent for every new team member.

Code Review Checklist

Beyond PHPCS automation, human code reviewers on your team should check for patterns that static analysis cannot catch:

Caching strategy: Is data that changes infrequently being cached? Are cache keys unique and deterministic? Are cache groups used appropriately? Is there a cache invalidation strategy?

Query efficiency: Are there N+1 query patterns (queries inside loops)? Could multiple queries be combined? Are fields => 'ids' or no_found_rows => true used where appropriate?

Error handling: Are wp_remote_get() responses checked with is_wp_error()? Are fallbacks in place for when external APIs are unavailable? Do error conditions produce meaningful log messages?

Hook usage: Are actions and filters used correctly? Are hook callbacks using appropriate priority values? Are hooks being removed from parent themes or plugins safely?

Data architecture: Are post meta queries being used for data that should be a taxonomy? Are custom post types defined with appropriate capability mappings? Are REST API endpoints registered with proper permission callbacks?

Branch Protection and CI Integration

Configure your repository’s branch protection rules to require PHPCS checks to pass before merging:

# .github/workflows/phpcs.yml
name: PHPCS

on:
  pull_request:
    paths:
      - '**.php'

jobs:
  phpcs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.0'
          tools: composer

      - name: Install dependencies
        run: composer install --no-progress --prefer-dist

      - name: Run PHPCS
        run: vendor/bin/phpcs --report=checkstyle | cs2pr

The cs2pr tool converts PHPCS checkstyle output into GitHub PR annotations, so violations appear as inline comments on the PR diff, similar to the VIP bot’s behavior. This gives developers immediate feedback without waiting for VIP’s bot.

Gradual Adoption for Legacy Projects

If you are bringing an existing codebase onto VIP, running PHPCS for the first time can produce thousands of violations. Fixing them all at once is impractical. A phased approach works better.

Phase 1: Baseline. Run PHPCS and save the output as a baseline file using the --report-file option. This documents your starting point.

vendor/bin/phpcs --standard=WordPress-VIP-Go --report=json --report-file=phpcs-baseline.json .

Phase 2: New code only. Configure your CI pipeline to run PHPCS only on files changed in each PR. This prevents new violations from entering the codebase while you work on fixing legacy issues.

Phase 3: Directory-by-directory cleanup. Tackle violations one directory at a time. Start with the most critical files (authentication, payment processing, data handling) and work outward to less critical areas (display templates, admin UI).

Phase 4: Full enforcement. Once the baseline is cleared, switch to full-project scanning in CI and reject any PR that introduces new violations.

This approach takes longer but is sustainable. Teams that try to fix everything at once often introduce new bugs by making hasty, untested changes to satisfy the linter.

Documentation of Exceptions

Some projects have legitimate needs for code that violates VIP standards. When this happens, document the exception formally:

/**
 * Custom database query for analytics aggregation.
 *
 * This query cannot use WP_Query because it aggregates data across
 * custom tables that are not managed by the WordPress post system.
 *
 * Caching: Results are cached for 1 hour in the 'analytics' cache group.
 * Cache invalidation: Cache is cleared on the 'save_post' hook for
 * the 'analytics_report' post type.
 *
 * Reviewed and approved by VIP team: Ticket #12345
 *
 * @since 2.1.0
 */
function get_analytics_summary() {
    $cache_key = 'analytics_summary_' . gmdate( 'Y-m-d-H' );
    $cached    = wp_cache_get( $cache_key, 'analytics' );

    if ( false !== $cached ) {
        return $cached;
    }

    global $wpdb;

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cached above; custom table requires direct query. VIP-approved in ticket #12345.
    $results = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT DATE(created_at) as report_date, COUNT(*) as total, SUM(page_views) as views
            FROM {$wpdb->prefix}analytics_events
            WHERE created_at >= %s
            GROUP BY DATE(created_at)
            ORDER BY report_date DESC
            LIMIT %d",
            gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
            30
        )
    );

    wp_cache_set( $cache_key, $results, 'analytics', HOUR_IN_SECONDS );

    return $results;
}

The inline comment explains why the rule is being suppressed, references the caching strategy, and includes the VIP approval ticket number. This is the gold standard for phpcs:ignore documentation.

Advanced PHPCS Configuration for VIP Projects

As your project matures, you may need to customize your PHPCS configuration beyond the basic setup. Here are patterns that experienced VIP developers use.

Custom Sniff Severity Levels

You can adjust the severity of specific rules without disabling them entirely. This is useful when you want to treat certain warnings as errors (to block commits) or certain errors as warnings (to allow them temporarily during a migration):

<!-- Treat slow meta queries as errors rather than warnings -->
<rule ref="WordPress.DB.SlowDBQuery">
    <type>error</type>
</rule>

<!-- Treat missing inline documentation as a warning during migration -->
<rule ref="Squiz.Commenting.FunctionComment.Missing">
    <type>warning</type>
</rule>

Per-Directory Rule Overrides

Different parts of your codebase may have different requirements. A CLI script might legitimately need different rules than a theme template:

<!-- Allow direct DB queries in CLI migration scripts -->
<rule ref="WordPress.DB.DirectDatabaseQuery">
    <exclude-pattern>*/cli/*.php</exclude-pattern>
</rule>

<!-- Stricter escaping rules for template files -->
<rule ref="WordPress.Security.EscapeOutput">
    <properties>
        <property name="customAutoEscapedFunctions" type="array">
            <element value="my_custom_escape_function" />
        </property>
    </properties>
</rule>

Registering Custom Auto-Escaped Functions

If your project includes custom escaping functions (wrappers around WordPress escaping with additional logic), you can register them with the escape output sniff so they do not trigger false positives:

<rule ref="WordPress.Security.EscapeOutput">
    <properties>
        <property name="customAutoEscapedFunctions" type="array">
            <element value="my_kses_svg" />
            <element value="my_escape_color_hex" />
        </property>
    </properties>
</rule>

Similarly, you can register custom sanitization functions:

<rule ref="WordPress.Security.ValidatedSanitizedInput">
    <properties>
        <property name="customSanitizingFunctions" type="array">
            <element value="my_sanitize_color" />
            <element value="my_sanitize_coordinates" />
        </property>
    </properties>
</rule>

These registrations tell PHPCS to treat your custom functions the same way it treats the built-in WordPress equivalents.

Running PHPCS with Multiple Reports

During development, different report formats serve different purposes:

# Quick summary of how many violations exist
vendor/bin/phpcs --report=summary

# Detailed violations with source rule names (useful for fixing)
vendor/bin/phpcs --report=source

# Full report with file names and line numbers
vendor/bin/phpcs --report=full

# Machine-readable output for CI
vendor/bin/phpcs --report=checkstyle --report-file=phpcs-report.xml

# Combined: summary on screen, checkstyle to file
vendor/bin/phpcs --report=summary --report-checkstyle=phpcs-report.xml

The --report=source format is particularly valuable when tackling a legacy codebase. It groups violations by rule name and shows the count for each, letting you prioritize which categories of violations to fix first.

Real-World Patterns: Putting It All Together

Let us walk through a realistic scenario. You are building a feature that displays a list of upcoming events from an external API, with an admin settings page to configure the API key.

The Settings Page (Admin Side)

/**
 * Register plugin settings.
 *
 * @since 1.0.0
 */
function my_events_register_settings() {
    register_setting(
        'my_events_settings',
        'my_events_api_key',
        array(
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_text_field',
            'default'           => '',
        )
    );
}
add_action( 'admin_init', 'my_events_register_settings' );

/**
 * Render the settings page.
 *
 * @since 1.0.0
 */
function my_events_settings_page() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <form action="options.php" method="post">
            <?php
            settings_fields( 'my_events_settings' );
            do_settings_sections( 'my_events_settings' );
            ?>
            <table class="form-table">
                <tr>
                    <th scope="row">
                        <label for="my_events_api_key">
                            <?php esc_html_e( 'API Key', 'my-events' ); ?>
                        </label>
                    </th>
                    <td>
                        <input
                            type="text"
                            id="my_events_api_key"
                            name="my_events_api_key"
                            value="<?php echo esc_attr( get_option( 'my_events_api_key', '' ) ); ?>"
                            class="regular-text"
                        />
                    </td>
                </tr>
            </table>
            <?php submit_button(); ?>
        </form>
    </div>
    <?php
}

This settings page is VIP-compliant because: it uses register_setting() with a sanitize callback, it checks current_user_can() for access control, it escapes all output with esc_html(), esc_attr(), and esc_html_e(), and it uses the Settings API which handles nonce verification internally.

The API Integration

/**
 * Fetch events from the external API.
 *
 * Results are cached for 15 minutes to reduce API calls and improve
 * page load performance.
 *
 * @since 1.0.0
 *
 * @param int $count Number of events to fetch. Default 10.
 * @return array|false Array of event objects, or false on failure.
 */
function my_events_fetch_events( $count = 10 ) {
    $count     = min( absint( $count ), 50 ); // Cap at 50 events
    $cache_key = 'my_events_list_' . $count;
    $cached    = wp_cache_get( $cache_key, 'my-events' );

    if ( false !== $cached ) {
        return $cached;
    }

    $api_key = get_option( 'my_events_api_key', '' );

    if ( empty( $api_key ) ) {
        return false;
    }

    $api_url = add_query_arg(
        array(
            'key'   => $api_key,
            'limit' => $count,
        ),
        'https://api.example.com/v2/events'
    );

    $response = wp_safe_remote_get(
        $api_url,
        array(
            'timeout'    => 10,
            'user-agent' => 'MyEventsPlugin/1.0',
            'headers'    => array(
                'Accept' => 'application/json',
            ),
        )
    );

    if ( is_wp_error( $response ) ) {
        // Log the error for debugging
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging behind WP_DEBUG flag.
                sprintf(
                    'My Events API error: %s',
                    $response->get_error_message()
                )
            );
        }
        return false;
    }

    $status = wp_remote_retrieve_response_code( $response );

    if ( 200 !== $status ) {
        return false;
    }

    $body = wp_remote_retrieve_body( $response );
    $data = json_decode( $body, true );

    if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
        return false;
    }

    // Sanitize API response data before caching
    $events = array_map( 'my_events_sanitize_event', $data );

    wp_cache_set( $cache_key, $events, 'my-events', 15 * MINUTE_IN_SECONDS );

    return $events;
}

/**
 * Sanitize a single event object from the API.
 *
 * @since 1.0.0
 *
 * @param array $event Raw event data from the API.
 * @return array Sanitized event data.
 */
function my_events_sanitize_event( $event ) {
    return array(
        'title'       => sanitize_text_field( $event['title'] ?? '' ),
        'description' => wp_kses_post( $event['description'] ?? '' ),
        'date'        => sanitize_text_field( $event['date'] ?? '' ),
        'url'         => esc_url_raw( $event['url'] ?? '' ),
        'location'    => sanitize_text_field( $event['location'] ?? '' ),
    );
}

This API integration is VIP-compliant because: it caches results with wp_cache_get()/wp_cache_set(), it uses wp_safe_remote_get() instead of cURL or file_get_contents(), it checks for errors at every step, it sanitizes external data before caching, and it caps the request count to prevent unbounded API calls.

The Display Template

/**
 * Render the events list shortcode.
 *
 * @since 1.0.0
 *
 * @param array $atts Shortcode attributes.
 * @return string HTML output.
 */
function my_events_shortcode( $atts ) {
    $atts = shortcode_atts(
        array(
            'count' => 10,
        ),
        $atts,
        'my_events'
    );

    $events = my_events_fetch_events( absint( $atts['count'] ) );

    if ( empty( $events ) ) {
        return '<p>' . esc_html__( 'No upcoming events found.', 'my-events' ) . '</p>';
    }

    $output = '<div class="my-events-list">';

    foreach ( $events as $event ) {
        $output .= sprintf(
            '<div class="my-event-item">
                <h3><a href="%1$s">%2$s</a></h3>
                <time datetime="%3$s">%4$s</time>
                <p class="location">%5$s</p>
                <div class="description">%6$s</div>
            </div>',
            esc_url( $event['url'] ),
            esc_html( $event['title'] ),
            esc_attr( $event['date'] ),
            esc_html( $event['date'] ),
            esc_html( $event['location'] ),
            wp_kses_post( $event['description'] )
        );
    }

    $output .= '</div>';

    return $output;
}
add_shortcode( 'my_events', 'my_events_shortcode' );

Every dynamic value in the template is escaped at the point of output. The escaping function matches the context: esc_url() for the href, esc_html() for text content, esc_attr() for the datetime attribute, and wp_kses_post() for the description that may contain permitted HTML.

Common Mistakes Even Experienced Developers Make

After reviewing hundreds of VIP pull requests over the years, certain mistakes appear repeatedly, even from senior developers.

Caching with Non-Deterministic Keys

// MISTAKE: Cache key includes a timestamp, defeating the purpose of caching
$cache_key = 'my_data_' . time();
$data      = wp_cache_get( $cache_key, 'my-plugin' );
// This ALWAYS misses the cache because the key changes every second.

Cache keys must be deterministic for the same input. Use the parameters that define the data being cached, not the time of the request.

Forgetting to Handle Cache Stampede

When a cached value expires, multiple simultaneous requests may all try to regenerate it at the same time. On a high-traffic VIP site, this can overwhelm the database.

// BETTER: Use a lock to prevent cache stampede
function get_expensive_data() {
    $cache_key = 'expensive_data';
    $lock_key  = $cache_key . '_lock';
    $data      = wp_cache_get( $cache_key, 'my-plugin' );

    if ( false !== $data ) {
        return $data;
    }

    // Try to acquire a lock
    $lock = wp_cache_add( $lock_key, 1, 'my-plugin', 30 );

    if ( ! $lock ) {
        // Another process is regenerating; return stale data or empty
        return wp_cache_get( $cache_key . '_stale', 'my-plugin' );
    }

    // Regenerate the data
    $data = expensive_computation();

    // Store both fresh and stale copies
    wp_cache_set( $cache_key, $data, 'my-plugin', HOUR_IN_SECONDS );
    wp_cache_set( $cache_key . '_stale', $data, 'my-plugin', 2 * HOUR_IN_SECONDS );

    // Release the lock
    wp_cache_delete( $lock_key, 'my-plugin' );

    return $data;
}

Using wp_cache_set Without a Group

// MISTAKE: No cache group, pollutes the default cache namespace
wp_cache_set( 'my_key', $data );

// CORRECT: Use a cache group for organization and flush control
wp_cache_set( 'my_key', $data, 'my-plugin', HOUR_IN_SECONDS );

Cache groups let you flush all of your plugin’s cached data without affecting other plugins. On VIP, where the object cache is shared, this is particularly important.

Escaping Translation Strings Incorrectly

// MISTAKE: Escaping inside the translation function
echo __( esc_html( 'Hello World' ), 'my-plugin' );

// CORRECT: Escape outside the translation function
echo esc_html( __( 'Hello World', 'my-plugin' ) );

// EVEN BETTER: Use the _e() variant with escaping, or dedicated functions
echo esc_html__( 'Hello World', 'my-plugin' );

// For HTML in translations, use wp_kses after translation
$allowed = array(
    'a'      => array( 'href' => array() ),
    'strong' => array(),
);
echo wp_kses(
    sprintf(
        /* translators: %s: the site name */
        __( 'Welcome to <strong>%s</strong>. <a href="/about">Learn more</a>.', 'my-plugin' ),
        get_bloginfo( 'name' )
    ),
    $allowed
);

Ignoring Return Types from WordPress Functions

// MISTAKE: Not checking return value of get_post()
$post = get_post( $post_id );
echo esc_html( $post->post_title ); // Fatal error if $post is null

// CORRECT: Always check return values
$post = get_post( $post_id );
if ( ! $post instanceof WP_Post ) {
    return;
}
echo esc_html( $post->post_title );

PHPCS will not always catch null pointer issues, but VIP human reviewers will. Defensive programming is expected on VIP projects.

Testing Your VIP Compliance

Before submitting code for VIP review, run a final full check:

# Full scan with all details
vendor/bin/phpcs --standard=WordPress-VIP-Go --report=full -s .

# The -s flag shows sniff names in the output, which is essential for
# understanding which specific rule triggered each violation.

# Check a single file in detail
vendor/bin/phpcs --standard=WordPress-VIP-Go --report=full -s path/to/file.php

# Auto-fix what can be auto-fixed
vendor/bin/phpcbf --standard=WordPress-VIP-Go .

The phpcbf (PHP Code Beautifier and Fixer) tool can automatically fix many style-related violations: spacing, alignment, bracket placement, and similar formatting issues. It cannot fix security or performance violations because those require architectural changes.

After running phpcbf, always review the changes it made. Automated fixes can occasionally change code behavior, particularly around string concatenation and array formatting.

Summary of Key VIP PHPCS Rules

For quick reference, here are the rules you will encounter most frequently on VIP projects, grouped by category:

Performance:

  • WordPressVIPMinimum.Performance.NoPaging: Disallows nopaging and posts_per_page => -1
  • WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude: Flags post__not_in
  • WordPress.DB.SlowDBQuery.slow_db_query_meta_query: Warns about meta queries
  • WordPress.DB.SlowDBQuery.slow_db_query_tax_query: Warns about tax queries (usually acceptable)

Security:

  • WordPress.Security.EscapeOutput.OutputNotEscaped: Unescaped output
  • WordPress.Security.NonceVerification.Missing: Missing nonce check
  • WordPress.Security.ValidatedSanitizedInput.InputNotSanitized: Unsanitized input
  • WordPress.Security.ValidatedSanitizedInput.MissingUnslash: Missing wp_unslash()
  • WordPressVIPMinimum.Security.ProperEscapingFunction: Wrong escape function for context

Database:

  • WordPress.DB.PreparedSQL.NotPrepared: Unprepared SQL query
  • WordPress.DB.DirectDatabaseQuery.DirectQuery: Direct $wpdb call
  • WordPress.DB.DirectDatabaseQuery.NoCaching: Direct query without caching

Restricted Functions:

  • WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_*: PHP file operation functions
  • WordPressVIPMinimum.Functions.RestrictedFunctions.curl_*: cURL functions
  • WordPressVIPMinimum.Functions.RestrictedFunctions.session_*: PHP session functions
  • WordPressVIPMinimum.Functions.RestrictedFunctions.dbDelta_dbDelta: dbDelta()

Each of these rules has a specific rationale tied to VIP’s infrastructure requirements. Understanding the “why” behind each rule, rather than just memorizing the fix, produces code that is genuinely better rather than merely compliant.

The goal of VIP standards is not to make your life harder. It is to ensure that the code you write performs reliably under the pressure of millions of page views. Every rule you satisfy today is a production incident you avoid tomorrow. Adopt these patterns, automate the enforcement, build team habits around quality, and the VIP code review process becomes a conversation between professionals rather than a frustrating gate.

Share this article

Rachel Torres

Senior WordPress developer and core contributor. Specializes in WordPress internals, performance optimization, and PHP best practices. Runs a WordPress consultancy in Austin, Texas.