Category: WordPress

  • Better Caching in WordPress

    Better Caching in WordPress

    Caching data in WordPress is easy. Caching data in WordPress in a good and performant way takes a bit more work. For instance, many developers commonly use the Transients API to cache data. As the lowest common denominator in caching, this is okay. It’ll get the job done, even on a $10/year shared hosting plan. But what we should do instead is leverage the WP_Object_Cache functions to provide more functionality and better features.

    For instance, let’s say we want to cache the result of an external API request. One way we could do this would be this way:

    Note: These examples are terrible, but I hope they get the point across!

    function get_api( $value) {
    	$value = absint( $value );
    	$api_data = get_transient( 'example-api-data-' . $value );
    
    	if ( false === $api_data ) {
    		$api_data = file_get_contents( 'https://example.com/api/' . $value );
    		set_transient( 'example-api-data-' . $value, $api_data, HOUR_IN_SECONDS * 6 );
    	}
    
    	return json_decode( $api_data );
    }Code language: PHP (php)

    What’s one way we could make this better by using the WP_Object_Cache functions? Well, what happens if the API data structure changes, and you need to invalidate every cache value? It would be pretty hard to know the exact transient keys that you’d need to clear, and clearing the entire cache is a bit too nuclear for this (but it would work). Instead, wp_cache_*() could be used, which includes the ability to use a cache group that can be changed:

    function get_api( $value) {
    	$value = absint( $value );
    	$cache_group = 'example-api-data';
    
    	$api_data = wp_cache_get( $value, $cache_group );
    
    	if ( false === $api_data ) {
    		$api_data = file_get_contents( 'https://example.com/api/' . $value );
    		wp_cache_set( $value, $api_data, $cache_group, HOUR_IN_SECONDS * 6 );
    	}
    
    	return json_decode( $api_data );
    }Code language: PHP (php)

    With this, if we ever need to invalidate the cache for this API, we just need to change the $cache_group value, and all cache requests will be new.


    Another common theme I see is caching too much data. Let’s say you’re going to do a slow WP_Query, and want to cache the results for better performance:

    function get_new_posts() {
    	$posts = wp_cache_get( 'new-posts' );
    
    	if ( false === $posts ) {
    		$posts = new WP_Query( 'posts_per_page=5000' );
    		wp_cache_set( 'new-posts', $posts );
    	}
    
    	return $posts;
    }Code language: PHP (php)

    Sure, that’s fine and it’ll work but… the WP_Query object is huge!

    echo strlen( serialize( new WP_Query( 'posts_per_page=500' ) ) ); … 2,430,748

    That’s 2.5 megs of data needing to be transferred out of cache on every pageload. If your cache is accessed across the network on another server, this introduces more delay as it has to transfer. Also, some caching solutions might put a limit on the size of an individual cache object–which means that an object like this might never be cached!

    Instead, we can just grab the IDs of the posts, and do a second, much faster query:

    function get_new_posts() {
    	$post_ids = wp_cache_get( 'new-posts' );
    
    	if ( false === $posts ) {
    		$post_ids = new WP_Query( 'posts_per_page=5000&fields=ids' );
    		wp_cache_set( 'new-posts', $posts->posts );
    	}
    
    	$posts = new WP_Query( [ 'post__in' => $post_ids ] );
    
    	return $posts;
    }Code language: PHP (php)

    echo strlen( serialize( $posts->posts ) ); … only 88,838 bytes, that’s like a 96%-something difference!

    I had a few more ideas for this post, but it’s been sitting as a draft forever and I don’t remember. It’s possible this topic might be revisited some day 🙂

  • Page Generation Graph for WordPress

    Page Generation Graph for WordPress

    At work, one of the more interesting customizations we have on WordPress.com for our VIP clients is a dashboard that contains custom widgets.  One of them is a page generation graph that shows the average page generation time for their site compared to all others.  That way they can judge their code performance against a good baseline.

    (more…)
  • Quick Tip: DreamHost cron and WP-CLI

    Quick Tip: DreamHost cron and WP-CLI

    If you’re hosting your WordPress website on DreamHost, and use their cron system to offload your WordPress faux-cron for better reliability, be careful of what version of PHP you have in your code.

    I recently had an issue where my cron events weren’t firing, and after enabling email output, I ended up with something like this PHP error message:

    Parse error: syntax error, unexpected '?' in /path/to/file.php on line 123

    It turns out that WP-CLI was running PHP 5.x via the DreamHost cron system.  I had PHP 7.x specific code in my theme.
    To fix this, I had to set the WP_CLI_PHP environment variable in my cron job:

    export WP_CLI_PHP=/usr/local/php72/bin/php
    wp cron event run --due-now --path=/home/path/to/wp/ --url=https://example.com/Code language: JavaScript (javascript)
  • Logging Failed Redirects

    Logging Failed Redirects

    WordPress has a built-in function called wp_safe_redirect().  This allows you to create redirects in code, but only to whitelisted domains (via the allowed_redirect_hosts filter).

    The downside to this is that you have to remember to whitelist the domains.  It’s easy to forget if you’re doing a lot of redirects, for instance with the WPCOM Legacy Redirector plugin.

    When this happens, all un-whitelisted redirects will be redirected by default to /wp-admin/ instead, and can cause a headache trying to figure out what’s going wrong.

    I had an idea to solve this problem.  A simple logging plugin that logs failed redirects and adds a dashboard widget to show the domains and number of times the redirect has failed:

    The code behind this:

    <?php
    class Emrikol_WSRD_Dashboard {
    	public static function instance() {
    		static $instance = false;
    		if ( ! $instance ) {
    			$instance = new Emrikol_WSRD_Dashboard();
    		}
    		return $instance;
    	}
    
    	public function __construct() {
    		add_action( 'init', array( $this, 'init' ) );
    		add_filter( 'allowed_redirect_hosts', array( $this, 'check_redirect' ), PHP_INT_MAX, 2 );
    	}
    
    	public function init() {
    		if ( $this->is_admin() && isset( $_GET['wsrd_delete'] ) && check_admin_referer( 'wsrd_delete' ) && isset( $_GET['ID'] ) ) {
    			$post_id = (int) $_GET['ID'];
    
    			if ( 'wsrd' !== get_post_type( $post_id ) ) {
    				// This isn't the right post type, abort!
    				add_action( 'admin_notices', array( $this, 'message_log_not_deleted' ) );
    				return;
    			}
    
    			$delete = wp_delete_post( $post_id, true );
    			wp_cache_delete( 'wsrd_report' );
    
    			if ( $delete ) {
    				add_action( 'admin_notices', array( $this, 'message_log_deleted' ) );
    			} else {
    				add_action( 'admin_notices', array( $this, 'message_log_not_deleted' ) );
    			}
    		}
    
    		$args = array(
    			'supports' => array( 'title' ),
    			'public'   => false,
    		);
    		register_post_type( 'wsrd', $args );
    
    		add_action( 'wp_dashboard_setup', array( $this, 'add_dashboard_widgets' ) );
    	}
    
    	public function add_dashboard_widgets() {
    		if ( $this->is_admin() ) {
    			wp_add_dashboard_widget( 'emrikol_wsrd_dashboard', 'Failed Safe Redirects', array( $this, 'show_admin_dashboard' ) );
    		}
    	}
    
    	public function check_redirect( $allowed_hosts, $redirect_host ) {
    		if ( ! in_array( $redirect_host, $allowed_hosts, true ) ) {
    			// No redirect, please record.
    			$found_host = new WP_Query( array(
    				'fields'                 => 'ids',
    				'name'                   => md5( $redirect_host ),
    				'post_type'              => 'wsrd',
    				'post_status'            => 'any',
    				'no_found_rows'          => true,
    				'posts_per_page'         => 1,
    				'update_post_term_cache' => false,
    				'update_post_meta_cache' => false,
    			) );
    
    			if ( empty( $found_host->posts ) ) {
    				// No past redirect log found, create one.
    				$args   = array(
    					'post_name'  => md5( $redirect_host ),
    					'post_title' => $redirect_host,
    					'post_type'  => 'wsrd',
    					'meta_input' => array(
    						'count' => 1,
    					),
    				);
    				$insert = wp_insert_post( $args );
    			} else {
    				// Found!  Update count.
    				$count = absint( get_post_meta( $found_host->posts[0], 'count', true ) );
    				$count++;
    				update_post_meta( $found_host->posts[0], 'count', $count );
    			}
    		}
    		// We don't want to modify, always return allowed hosts unharmed.
    		return $allowed_hosts;
    	}
    
    	public function show_admin_dashboard() {
    		global $wpdb;
    
    		$report = wp_cache_get( 'wsrd_report' );
    		if ( false === $report ) {
    			$report = $wpdb->get_results( "SELECT ID, post_title AS host, meta_value AS count FROM $wpdb->posts LEFT JOIN $wpdb->postmeta ON ( $wpdb->posts.ID = $wpdb->postmeta.post_id ) WHERE post_type='wsrd'  ORDER BY ABS( count ) DESC LIMIT 20;" );
    			wp_cache_set( 'wsrd_report', $report, 'default', MINUTE_IN_SECONDS * 5 );
    		}
    
    		?>
    		<style>
    			table#wsrd {
    				border-collapse: collapse;
    				width: 100%;
    			}
    			table#wsrd th {
    				background: #f5f5f5;
    			}
    
    			table#wsrd th, table#wsrd td {
    				border: 1px solid #f5f5f5;
    				padding: 8px;
    			}
    
    			table#wsrd tr:nth-child(even) {
    				background: #fafafa;
    			}
    		</style>
    		<div class="activity-block">
    			<?php if ( empty( $report ) ) : ?>
    			<p><strong>None Found!</strong></p>
    			<?php else : ?>
    			<table id="wsrd">
    				<thead>
    					<tr>
    						<th>Domain</th>
    						<th>Count</th>
    						<th>Control</th>
    					</tr>
    				</thead>
    				<tbody>
    					<?php foreach ( $report as $line ) : ?>
    						<tr>
    							<td><?php echo esc_html( $line->host ); ?></td>
    							<td><?php echo esc_html( $line->count ); ?></td>
    							<td><a href="<?php echo esc_url( wp_nonce_url( add_query_arg( array( 'wsrd_delete' => true, 'ID' => rawurlencode( $line->ID ) ), admin_url() ), 'wsrd_delete' ) ); ?>">Delete</a></td>
    						</tr>
    					<?php endforeach; ?>
    				</tbody>
    			</table>
    			<?php endif; ?>
    		</div>
    		<?php
    	}
    
    	public function message_log_deleted() {
    		echo '<div id="message" class="notice notice-success is-dismissible"><p>Redirect log deleted!</p></div>';
    	}
    
    	public function message_log_not_deleted() {
    		echo '<div id="message" class="notice notice-error is-dismissible"><p>Redirect log delete failed!</p></div>';
    	}
    
    
    	private function is_admin() {
    		if ( current_user_can( 'manage_options' ) ) {
    			return true;
    		}
    		return false;
    	}
    }
    Emrikol_WSRD_Dashboard::instance();
    Code language: HTML, XML (xml)

  • Purging All The Caches!

    Purging All The Caches!

    One of the best ways to ensure that a WordPress site–well any site really–stays performant and not broken is by leveraging caching.

    WordPress by default doesn’t do much caching other than some in-memory caching of objects, and the odd database caching via the Transients API.

    This site currently has three layers of caching:

    This means I have three different plugins that I have to manage with these caches:

    So if I am doing some development and want to purge one or more caches, I need to go dig around three different places to purge these, and that’s not fun.  To help combat this, I made myself a simple Admin Dashboard widget with quick access to purge each of these:

    Here’s the code:

    <?php
    class Emrikol_Cache_Dashboard {
    	public static function instance() {
    		static $instance = false;
    		if ( ! $instance ) {
    			$instance = new Emrikol_Cache_Dashboard();
    		}
    		return $instance;
    	}
    
    	public function __construct() {
    		add_action( 'init', array( $this, 'init' ) );
    	}
    
    	public function init() {
    		if ( $this->is_admin() && isset( $_GET['ead_purge_object_cache'] ) && check_admin_referer( 'manual_purge' ) ) {
    			$did_flush = wp_cache_flush();
    			if ( $did_flush ) {
    				add_action( 'admin_notices', array( $this, 'message_object_cache_purge_success' ) );
    			} else {
    				add_action( 'admin_notices', array( $this, 'message_object_cache_purge_failure' ) );
    			}
    		} elseif ( $this->is_admin() && isset( $_GET['ead_purge_wp_super_cache'] ) && check_admin_referer( 'manual_purge' ) ) {
    			global $file_prefix;
    			wp_cache_clean_cache( $file_prefix, true );
    			add_action( 'admin_notices', array( $this, 'message_wp_super_cache_purge_success' ) );
    		} elseif ( $this->is_admin() && isset( $_GET['ead_purge_OPcache'] ) && check_admin_referer( 'manual_purge' ) ) {
    			// Taken from: https://wordpress.org/plugins/flush-opcache/
    			// Check if file cache is enabled and delete it if enabled.
    			// phpcs:ignore WordPress.VIP.FileSystemWritesDisallow.file_ops_is_writable
    			if ( ini_get( 'OPcache.file_cache' ) && is_writable( ini_get( 'OPcache.file_cache' ) ) ) {
    				$files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( ini_get( 'OPcache.file_cache' ), RecursiveDirectoryIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST );
    				foreach ( $files as $fileinfo ) {
    					$todo = ( $fileinfo->isDir() ? 'rmdir' : 'unlink' );
    					$todo( $fileinfo->getRealPath() );
    				}
    			}
    
    			// Flush OPcache.
    			$did_flush = OPcache_reset();
    			if ( $did_flush ) {
    				add_action( 'admin_notices', array( $this, 'message_OPcache_purge_success' ) );
    			} else {
    				add_action( 'admin_notices', array( $this, 'message_OPcache_purge_failure' ) );
    			}
    		}
    
    		add_action( 'wp_dashboard_setup', array( $this, 'add_dashboard_widgets' ) );
    	}
    
    	public function add_dashboard_widgets() {
    		if ( $this->is_admin() ) {
    			wp_add_dashboard_widget( 'emrikol_admin_dashboard', 'Cache Control', array( $this, 'show_admin_dashboard' ) );
    		}
    	}
    
    	public function show_admin_dashboard() {
    		if ( false === get_parent_class( $GLOBALS['wp_object_cache'] ) ) {
    			// Persistent Object Cache detected.
    			?>
    			<div class="activity-block">
    				<span class="button"><a href="<?php echo esc_url( wp_nonce_url( admin_url( '?ead_purge_object_cache' ), 'manual_purge' ) ); ?>"><strong>Purge Object Cache</strong></a></span>
    				<p>Force a purge of your entire site's object cache.</p>
    			</div>
    			<?php
    		} else {
    			// Transients!
    			?>
    			<div class="activity-block">
    				<h3>Transients</h3>
    				<p>Transients cannot currently be removed manually.</p>
    			</div>
    			<?php
    		}
    		if ( function_exists( 'wp_cache_clean_cache' ) ) {
    			// WP Super Cache!
    			?>
    			<div class="activity-block">
    				<span class="button"><a href="<?php echo esc_url( wp_nonce_url( admin_url( '?ead_purge_wp_super_cache' ), 'manual_purge' ) ); ?>"><strong>Purge Page Cache</strong></a></span>
    				<p>Force a purge of your entire site's page cache.</p>
    			</div>
    			<?php
    		}
    		if ( function_exists( 'OPcache_reset' ) ) {
    			// PHP OPcache.
    			?>
    			<div class="activity-block">
    				<span class="button"><a href="<?php echo esc_url( wp_nonce_url( admin_url( '?ead_purge_OPcache' ), 'manual_purge' ) ); ?>"><strong>Purge PHP OPcache</strong></a></span>
    				<p>Force a purge of your entire site's PHP OPcache.</p>
    			</div>
    			<?php
    		}
    	}
    
    	public function message_wp_super_cache_purge_success() {
    		echo '<div id="message" class="notice notice-success is-dismissible"><p>Page Cache purged!</p></div>';
    	}
    
    	public function message_object_cache_purge_success() {
    		echo '<div id="message" class="notice notice-success is-dismissible"><p>Object Cache purged!</p></div>';
    	}
    
    	public function message_object_cache_purge_failure() {
    		echo '<div id="message" class="notice notice-error is-dismissible"><p>Object Cache purge failed!</p></div>';
    	}
    
    	public function message_OPcache_purge_success() {
    		echo '<div id="message" class="notice notice-success is-dismissible"><p>PHP OPcache purged!</p></div>';
    	}
    
    	public function message_OPcache_purge_failure() {
    		echo '<div id="message" class="notice notice-error is-dismissible"><p>PHP OPcache purge failed!</p></div>';
    	}
    
    	private function is_admin() {
    		if ( current_user_can( 'manage_options' ) ) {
    			return true;
    		}
    		return false;
    	}
    }
    Emrikol_Cache_Dashboard::instance();
    Code language: HTML, XML (xml)
  • Disabling WordPress Faux Cron

    Disabling WordPress Faux Cron

    The WordPress WP-Cron system is a decently okay faux cron system, but it has its problems, such as running on frontend requests and not running if no requests are coming through.

    WP-Cron works by: on every page load, a list of scheduled tasks is checked to see what needs to be run. Any tasks scheduled to be run will be run during that page load. WP-Cron does not run constantly as the system cron does; it is only triggered on page load. Scheduling errors could occur if you schedule a task for 2:00PM and no page loads occur until 5:00PM.

    From the WordPress Plugin Handbook

    These are problems because:

    • A heavy cron event can cause severe slowdown on random frontend requests, hurting page speeds.
    • Not running without requests can be bad for sites that are infrequently updated and heavily cached.

    The solution to this is to disable the built-in cron firing that’s done with pageviews, and use a system cron (or other service) to poll for cron events.

    Disabling the cron firing is done by adding this to the wp-config.php file:

    define( 'DISABLE_WP_CRON', true );Code language: JavaScript (javascript)

    For this site specifically, I use the “Cron Jobs” system of DreamHost to run this WP-CLI command every 10 minutes:

    wp cron event run --due-now --path=/path/to/derrick.blog/ --url=https://derrick.blog/Code language: JavaScript (javascript)

    This forces the cron to run and check for ready jobs every 10 minutes.  It’s possible that some cron events might run later than they “should” but in practice, I’ve seen this running more cron jobs than if I relied on page loads.

  • Quick Tip: Force Enable Auto-Updates in WordPress

    Quick Tip: Force Enable Auto-Updates in WordPress

    I know that auto-updates are a bit of a (#wpdrama) touchy subject, but I believe in them.

    In an mu-plugin I enable all auto-updates like so:

    <?php
    // Turn on auto-updates for everything
    if ( ! defined( 'IS_PRESSABLE' ) || ! IS_PRESSABLE ) {
    	add_filter( 'allow_major_auto_core_updates', '__return_true' );
    	add_filter( 'allow_minor_auto_core_updates', '__return_true' );
    }
    
    add_filter( 'auto_update_core', '__return_true' );
    add_filter( 'auto_update_plugin', '__return_true' );
    add_filter( 'auto_update_theme', '__return_true' );
    add_filter( 'auto_update_translation', '__return_true' );
    Code language: HTML, XML (xml)
  • Query Caching (and a little extra)

    Query Caching (and a little extra)

    By default, WordPress does not cache WP_Query queries.  Doing so can greatly improve performance.  The way I do this is via the Advanced Post Cache plugin:

    By running this plugin (hopefully as an mu-plugin) with a persistent object cache, WP_Query calls, along with get_post() calls (only if suppress_filters is false) will be cached.

    Bonus!

    Now that we’re caching queries, here’s how I do a little extra caching to squeeze out a tiny bit more performance:

    <?php
    // By default Jetpack does not cache responses from Instagram oembeds.
    add_filter( 'instagram_cache_oembed_api_response_body', '__return_true' );
    
    // Cache WP Dashboard Recent Posts Query
    add_filter( 'dashboard_recent_posts_query_args', 'cache_dashboard_recent_posts_query_args', 10, 1 );
    function cache_dashboard_recent_posts_query_args( $query_args ) {
    	$query_args['cache_results'] = true;
    	$query_args['suppress_filters'] = false;
    	return $query_args;
    }
    
    // Cache WP Dashboard Recent Drafts Query
    add_filter( 'dashboard_recent_drafts_query_args', 'cache_dashboard_recent_drafts_query_args', 10, 1 );
    function cache_dashboard_recent_drafts_query_args( $query_args ) {
    	$query_args['suppress_filters'] = false;
    	return $query_args;
    }
    
    // Cache comment counts, https://github.com/Automattic/vip-code-performance/blob/master/core-fix-comment-counts-caching.php
    add_filter( 'wp_count_comments', 'wpcom_vip_cache_full_comment_counts', 10, 2 );
    function wpcom_vip_cache_full_comment_counts( $counts = false , $post_id = 0 ){
    	//We are only caching the global comment counts for now since those are often in the millions while the per page one is usually more reasonable.
    	if ( 0 !== $post_id ) {
    		return $counts;
    	}
    
    	$cache_key = "vip-comments-{$post_id}";
    	$stats_object = wp_cache_get( $cache_key );
    
    	//retrieve comments in the same way wp_count_comments() does
    	if ( false === $stats_object ) {
    		$stats = get_comment_count( $post_id );
    		$stats['moderated'] = $stats['awaiting_moderation'];
    		unset( $stats['awaiting_moderation'] );
    		$stats_object = (object) $stats;
    
    		wp_cache_set( $cache_key, $stats_object, 'default', 30 * MINUTE_IN_SECONDS );
    	}
    
    	return $stats_object;
    }
    
    // Cache monthly media array.
    add_filter( 'media_library_months_with_files', 'wpcom_vip_media_library_months_with_files' );
    function wpcom_vip_media_library_months_with_files() {
    	$months = wp_cache_get( 'media_library_months_with_files', 'extra-caching' );
    
    	if ( false === $months ) {
    		global $wpdb;
    		$months = $wpdb->get_results( $wpdb->prepare( "
    			SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month
    			FROM $wpdb->posts
    			WHERE post_type = %s
    			ORDER BY post_date DESC
    			", 'attachment' )
    		);
    		wp_cache_set( 'media_library_months_with_files', $months, 'extra-caching' );
    	}
    
    	return $months;
    }
    
    add_action( 'add_attachment', 'media_library_months_with_files_bust_cache' );
    function media_library_months_with_files_bust_cache( $post_id ) {
    	if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
    		return;
    	}
    
    	// What month/year is the most recent attachment?
    	global $wpdb;
    	$months = $wpdb->get_results( $wpdb->prepare( "
    			SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month
    			FROM $wpdb->posts
    			WHERE post_type = %s
    			ORDER BY post_date DESC
    			LIMIT 1
    		", 'attachment' )
    	);
    
    	// Simplify by assigning the object to $months
    	$months = array_shift( array_values( $months ) );
    
    	// Compare the dates of the new, and most recent, attachment
    	if (
    		! $months->year == get_the_time( 'Y', $post_id ) &&
    		! $months->month == get_the_time( 'm', $post_id )
    	) {
    		// the new attachment is not in the same month/year as the
    		// most recent attachment, so we need to refresh the transient
    		wp_cache_delete( 'media_library_months_with_files', 'extra-caching' );
    	}
    }Code language: HTML, XML (xml)