WP Performance
Below we’ll look at the main points you should pay attention to when writing PHP code for WordPress. These recommendations will help reduce the number of unnecessary queries, lower the server load, and make the code safer and easier to understand.
The material is relevant for WordPress 7.0.2. The code is designed for PHP 7.4 and newer.
Efficient Database Queries
To retrieve posts, WP_Query and get_posts() are usually used. Inside get_posts() there is also WP_Query, so in terms of performance these approaches are similar.
get_posts() is convenient when you just need to get an array of posts. For a separate loop and working with pagination, use WP_Query instead. To change the main page query, use the pre_get_posts hook, not query_posts().
Also read: 3 ways to build loops in WordPress
Disable unnecessary work
In addition to the main SQL query, WP_Query can calculate the total number of posts and populate caches for meta-fields and terms. You can control this via arguments:
'no_found_rows' => true- don’t count the total number of posts if pagination is not needed.'update_post_meta_cache' => false- don’t load post meta-fields into cache if they won’t be used later.'update_post_term_cache' => false- don’t load post terms into cache if they aren’t needed.'fields' => 'ids'- return only post IDs.
For example, get the IDs of the latest 20 posts without counting pages:
$post_ids = get_posts( [ 'post_type' => 'post', 'posts_per_page' => 20, 'fields' => 'ids', ] );
get_posts() already includes no_found_rows, so there’s no need to pass this argument here.
If you need post objects, but don’t need their meta-fields and terms:
$query = new WP_Query( [ 'post_type' => 'post', 'posts_per_page' => 20, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] );
Don’t disable caches automatically in all queries. If later you call get_post_meta(), get_the_terms(), or similar functions, the enabled cache usually reduces the number of DB queries.
Starting with WordPress 6.1, identical WP_Query objects are cached. With a persistent object cache, the repeated SQL query might not run at all until the corresponding cache is cleared.
Limit the number of results
Do not use 'posts_per_page' => -1 if the number of posts can grow. Such a query will once try to load tens of thousands of objects into memory.
Set a reasonable limit and use pagination or batch processing. For background processing of a large number of posts, choose small batches, for example 100–500 posts each.
Be careful with meta-queries
Values in the meta-data tables are not indexed the same way as regular columns of the posts table. Therefore, complex meta_query, sorting by a meta-field, and searching by meta_value can work slowly.
For single queries this is fine. If you need to constantly filter thousands of posts by a value, it’s better to use a taxonomy, a separate column, or your own table.
The same applies to queries with a large number of conditions and JOIN. Don’t move filtering into PHP automatically: first measure both variants. PHP filtering may require loading much more posts and can break pagination.
Caching
Caching is needed for data whose retrieval noticeably loads the database, the processor, or an external service. Don’t cache everything indiscriminately: a simple query to an already warmed WordPress cache can be cheaper than having your own caching layer.
Object cache and transients
Functions wp_cache_get() and wp_cache_set() work with the object cache. Without Redis, Memcached, or another persistent storage, this cache lives only within a single page request to the site.
Transients API stores temporary data between requests even without a persistent object cache. With Redis or Memcached, transients are usually stored there.
The cache might disappear before the specified expiration time, so the code must always be able to recreate the data:
$items = get_transient( 'my_plugin_items' );
if ( false === $items ) {
$items = my_plugin_load_items();
set_transient( 'my_plugin_items', $items, HOUR_IN_SECONDS );
}
In the cache key add parameters that affect the result. Clear the cache when the source data changes, and use the storage lifetime as an additional safety net.
Full-page cache
A full-page cache stores the ready-made HTML and returns it without running WordPress in the usual way. It can be provided by hosting, a web server, a CDN, or a plugin.
With such caching you cannot generate personal data for the visitor on the server and expect every visitor to receive their own version of the page. Do personalization on the client side or exclude such pages from the cache.
External requests
A request to a third-party API should not be executed on every page load unless necessary. Use wp_remote_get(), set a reasonable timeout, check is_wp_error() and the response code. Cache the successful result.
If the external service is temporarily unavailable, where possible, return the previous data rather than delaying or breaking the entire page.
Proper data storage
Choose a storage based on how the data will be used:
- Options - plugin or theme settings.
- Meta-fields - data related to a specific post, user, comment, or term.
- Taxonomies - grouping and frequent filtering of posts.
- Post types - separate entities with a title, status, author, and other possible WordPress features.
- Custom tables - large volumes of uniform data, frequent selections, sorting, and aggregation by separate columns.
Don’t overload options
The number of rows in wp_options itself tells little. What matters more is the size of automatically loaded options. They are read when WordPress starts on every request.
Starting with WordPress 6.6, the default value of the $autoload argument is null, and WordPress can determine it automatically. If the choice is obvious, pass true only for a small option that is needed on almost every page. For an option used on a single admin page, pass false:
add_option( 'my_plugin_settings', $settings, '', false );
String values 'yes' and 'no' for $autoload are deprecated. Use true, false, or null.
You can check the size of autoloaded options in the "Tools -> Site Health" section. WordPress considers a potential problem to be a total size above 800 KB, but this is a guideline, not a universal limit.
REST API and AJAX
For a new public API, use register_rest_route(), not your own rewrite rules. The REST API already knows how to parse parameters, return errors, and JSON responses.
Each route must have permission_callback. It checks whether the user has permission to perform the action:
add_action( 'rest_api_init', 'my_plugin_register_rest_routes' );
function my_plugin_register_rest_routes() {
register_rest_route(
'my-plugin/v1',
'/settings',
[
'methods' => 'POST',
'callback' => 'my_plugin_update_settings',
'permission_callback' => static function () {
return current_user_can( 'manage_options' );
},
]
);
}
For a fully public route you can use __return_true, but this must be a conscious decision.
admin-ajax.php is still suitable for existing code and simple actions in the admin area. For a new REST API, a REST route is usually clearer and more convenient.
Security
Read more about security in a separate section.
A secure handler usually performs four separate checks:
- checks user permissions;
- checks nonce;
- validates or sanitizes input data;
- escapes data when outputting it.
One check doesn’t replace the other.
Check permissions and nonce
Nonce protects the request from CSRF, but it does not confirm the user’s permission to perform the action. Therefore, alongside the nonce, check current_user_can().
For a typical admin form you can use wp_nonce_field() and check_admin_referer():
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Access denied.' );
}
check_admin_referer( 'my_plugin_save_settings' );
For AJAX there is check_ajax_referer(). For REST API, permissions are checked in permission_callback.
Process input data
Data from $_GET, $_POST, and $_REQUEST in WordPress may contain slashes added. First use wp_unslash(), then apply an appropriate sanitization or validation function:
$title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
If only заранее известные values are allowed, validate them against a whitelist with strict comparison:
$layout = isset( $_POST['layout'] )
? sanitize_key( wp_unslash( $_POST['layout'] ) )
: '';
if ( ! in_array( $layout, array( 'grid', 'list' ), true ) ) {
$layout = 'grid';
}
Escape on output
The escaping function depends on where the value will be inserted:
- esc_html() - plain text inside HTML.
- esc_attr() - an HTML attribute value.
- esc_url() - a URL in
href,src, and similar attributes. - wp_kses_post() - HTML with safe tags allowed for post content.
- wp_json_encode() - passing data to JavaScript.
Escape as close to the output location as possible:
<a href="<?php echo esc_url( $url ); ?>"> <?php echo esc_html( $title ); ?> </a>
Prepare SQL queries
Whenever possible, use the WordPress API. If you need a direct SQL query, substitute values using $wpdb->prepare():
global $wpdb; $post = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE ID = %d", $post_id ) );
Placeholders don’t need to be enclosed in quotes:
%d- an integer;%f- a floating-point number;%s- a string;%i- a table or column name.
For LIKE, first use $wpdb->esc_like(), and then pass the resulting string into $wpdb->prepare() via %s.
Code structure
Separate the theme and the plugin responsibilities
A plugin should store data and add functionality. The theme is responsible for the appearance. After switching the theme, the site data and core logic should not disappear.
If the theme and plugin complement each other, check whether the required function, class, or theme support exists. Missing one part should not lead to a fatal error.
Avoid naming conflicts
For classes, use a unique namespace. Global functions, constants, hooks, options, and meta-field keys should be prefixed with a unique project prefix.
namespace WPKama\My_Plugin;
function register_hooks() {
add_action( 'init', __NAMESPACE__ . '\\register_post_types' );
}
Make dependencies explicit
Don’t hide important dependencies in global variables and singletons. Pass them into the constructor or method. This makes the code easier to test and replace.
A class should not simultaneously register an admin page, run SQL queries, send HTTP requests, and output HTML. Separate these tasks only where it truly simplifies the code.
You can use third-party libraries. Connect them via Composer, and keep track of the license, size, and compatibility. In a distributable plugin, consider possible conflicts from the same dependencies having different versions.
Enqueue scripts and styles
Enqueue files via wp_enqueue_script() and wp_enqueue_style(). Specify dependencies and load the file only on the pages where it’s needed.
For independent scripts you can use the defer or async strategy. WordPress itself accounts for the dependency tree:
wp_enqueue_script( 'my-plugin-front', plugins_url( 'assets/front.js', __FILE__ ), array(), MY_PLUGIN_VERSION, array( 'strategy' => 'defer', 'in_footer' => true, ) );
Use async only for a script that does not depend on the execution order of other files.
Change the file version when its contents change. In a published plugin, the plugin’s own version is usually used. During development, it’s convenient to use filemtime().
Translations
Wrap user-facing strings in translation functions. The string and the text domain must be literals, otherwise translation build tools may not find them.
printf( /* translators: %d: Number of remaining items. */ esc_html( _n( '%d item left', '%d items left', $count, 'my-plugin' ) ), $count );
The plugin text domain must match its slug. The Text Domain field is not required with WordPress 4.6 if the domain matches the slug, but you can keep it for clarity.
Translated strings are escaped using the same rules as the rest of the data. For plain text, it’s convenient to use esc_html__() and esc_html_e().
Quality and compatibility
WordPress 7.0 requires PHP 7.4 or newer, and the recommended PHP version is 8.3 or newer. If the plugin supports only a newer PHP version, clearly specify it in Requires PHP and test the code against all stated versions.
Use WordPress Coding Standards and run code checks with PHP_CodeSniffer using the WordPress/WordPress-Coding-Standards package. A static analyzer, for example PHPStan, is useful for finding type-related errors.
With automated tests, primarily cover calculations, access permissions, data saving, and fixed bugs. For code tightly connected to WordPress, integration tests are usually more useful than isolated unit tests.
Comment the reasons for non-obvious decisions. Don’t repeat in comments what can already be seen in the code.
Use PHP sessions only when you truly can’t do without them. They complicate full-page caching and make site behavior on multiple servers harder. For short-lived state, cookies, client storage, user meta-fields, or a separate server-side storage are often enough.
Check results with measurements
Not every additional function or SQL construct creates a noticeable problem. First find the bottleneck, then change the code.
The number and timing of SQL queries is conveniently monitored with Query Monitor. Measure PHP load with a profiler, and check the behavior of persistent object caching and full-page caching in an environment similar to production.
--
The material is based on 10up Engineering Best Practices, but the recommendations were reworked taking into account modern WordPress APIs.