In my last article for Torque, I showed you how to merge multiple sources into one blog post on a static HTML site using the REST API and JavaScript. This time, I will show you how to accomplish something similar using PHP.
The last article was written to work on any type of site. In the example, I used a simple HTML site I created for my business. I then used multiple WordPress sites to source content using WordPress REST API. To combine them, I used JavaScript because that side did not use a server-side language like PHP that could perform or cache the queries.
You can use that technique with a WordPress site instead of creating one in HTML, but if you’re planning on going with all WordPress, or really any site with a CMS, you have more options. In the JavaScript demonstration, I used local storage to cache responses in the browser. While this works, it doesn’t provide for server-side caching.
The client-side caching I showed in the last article is useful, but it still requires every single client to get the data from all three sites being used as sources. In this article, I will show how to use one WordPress site to act as a connector for all of the other sites, which will allow for one site to make and cache all of the requests to the other sites.
First A Simple Widget
I sell a WordPress REST API video course using a WordPress site, which is a totally separate install from my personal site. It is on a subdomain of the main site, but I didn’t use WordPress multisite for a lot of reasons.
One challenge I ran into was how to show a recent posts widget on the course site. The course site has no posts, those are all on the main site. To solve this, I made a copy of WordPress’ recent posts widget and replaced its use of WP_Query with a call to the posts endpoint of the WordPress REST API on the main site.
Of course, this adds a render-blocking HTTP request to the page, so I use a transient to cache the response of that request instead of generating tons of request with the same response. Here is the code for rendering the widget, it’s a good basic example of the more complex code I’m going to show later in this article:
<?php //see https://github.com/Shelob9/remote-recent-posts/blob/master/widget.php class Josh_Remote_Recent_Posts extends WP_Widget { public function widget( $args, $instance ) { if ( ! isset( $args['widget_id'] ) ) { $args['widget_id'] = $this->id; } $cache_time = ( ! empty( $instance['cache_time'] ) ) ? absint( $instance['cache_time'] ) : 5; $cache_key = md5( __CLASS__ . implode( $args ) ); if( 0 < $cache_time && false != ($cached = get_transient( $cache_key ) ) ){ echo $cached; return; } ob_start(); $title = ( ! empty( $instance['title'] ) ) ? $instance['title'] : __( 'Recent Posts', 'josh-remote-recent-posts' ); /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ $title = apply_filters( 'widget_title', $title, $instance, $this->id_base ); $number = ( ! empty( $instance['number'] ) ) ? absint( $instance['number'] ) : 5; if ( ! $number ){ $number = 5; } $url = trailingslashit( $instance[ 'url' ] ) . 'wp/v2/posts'; $url = add_query_arg( 'filter[posts_per_page]', $number, $url ); $r = wp_safe_remote_get( $url ); if( ! is_wp_error( $r ) ){ $posts = json_decode( wp_remote_retrieve_body( $r ) ); if( ! empty( $posts ) ){ echo $args['before_widget']; if ( $title ) { echo $args[ 'before_title' ] . $title . $args[ 'after_title' ]; }?> <ul> <?php foreach( $posts as $post ) : ?> <li> <a href="<?php echo esc_url( $post->link ) ?>"> <?php echo esc_html( $post->title->rendered ); ?> </a> </li> <?php endforeach; ?> </ul> <?php echo $args['after_widget']; } } $output = ob_get_clean(); if( 0 < $cache_time ){ set_transient( $cache_key, $output, $cache_time ); } echo $output; } } Raw
That’s part of a full widget class. The remainder of that class is basically the same as the core widget it is copied from. You can read the whole plugin here.
The most important thing to see from this is that the transient API is used to hold the response of the request. The first time the page is called, the request is made to the remote site. Every other time, the request is one quick call to either the current site’s object cache or database.
Getting More Complicated
That widget was a simple example, but the code was single use and from a single site. Now let’s build two PHP classes that will make using multiple data sources in one WordPress site easy. One class will be our API for calling the remote sites or getting the data from the cache, while the other will merge the results.
This is a great way of working when you have one site that needs to show posts from many other sites in separate installs. You may be doing this to solve scalability issues or because the different sites are controlled by other companies or other parts of your organization.
Here is our first class. This takes the URL for an endpoint and then can be used to get all of the posts from that endpoint. It has built in caching and pagination. Take a look at it first and then I’ll discuss highlights:
<?php class Endpoint { /** @var string */ protected $url; /** @var array */ protected $posts = []; /** * Endpoint constructor. * * @param string $url Url for endpoint to request */ public function __construct( $url ){ $this->url = $url; $this->get_cache(); } /** * Get one page of posts * * @param int $page * * @return array Array of posts, will be empty if page can't be found. */ public function get_posts( $page = 1){ if( isset( $this->posts[ $page ] ) ){ return $this->posts[ $page ]; }else{ $this->make_request( $page ); if( isset( $this->posts[ $page ] ) ) { return $this->posts[ $page ]; } } return []; } /** * Clear cache */ public function clear_cache(){ delete_transient( $this->cache_key() ); } /** * Get a page of posts from remote API * * @param $page */ protected function make_request( $page ){ $request = wp_remote_get( add_query_arg( 'page', (int) $page, $this->url ) ); if( ! is_wp_error( $request ) && 200 == wp_remote_retrieve_response_code( $request ) ){ $this->posts[ $page ] = json_decode( wp_remote_retrieve_body( $request ) ); } } /** * Reset cache for a day */ protected function set_cache(){ if ( ! empty( $this->posts ) ) { set_transient( $this->cache_key(), $this->posts, DAY_IN_SECONDS ); } } /** * Get cached posts if possible */ protected function get_cache(){ if( is_array( $posts = get_transient( $this->cache_key() ) ) ){ $this->posts = $posts; } } /** * Form cache key based on URL * * @return string */ protected function cache_key(){ return md5( preg_replace( "(^https?://)", "", $this->url ) ); } }
The most important part of this is the get_posts() method. It checks if we already have that page of posts in the posts property of the class. Since that property is set from the cache when the class is instantiated, it should be there if that page has been requested in the last day through this class.
If those posts are not already in this object, a request to the remote site is made for them. After making the request, the cache is refreshed with the new data added.
Depending on your site’s needs, you may wish to make the cache work on a per page basis. Also, if you know a persistent object cache is in use on your site, using wp_cache_get() over get_transient() is probably a smart change, just make sure to change the set and delete functions to match.
Next, we will need a class that can take one or more objects created by this Endpoint class and merge and sort their posts. Here is that class:
<?php class MultiBlog { /** @var array */ protected $posts = []; /** @var array */ protected $endpoints = []; /** @var bool */ protected $looped = false; /** @var int */ protected $page; /** * MultiBlog constructor. * * @param int $page What page of results to get */ public function __construct( $page = 1 ){ $this->page = 1; } /** * Add an endpoint to theis collection * * @param \shelob9\multiblog\Endpoint $endpoint */ public function add_endpoint( Endpoint $endpoint ){ $this->endpoints[] = $endpoint; } public function get_posts( $page = 1){ if( isset( $this->posts[ $page ] ) ){ return $this->posts[ $page ]; }else{ $this->merge(); if( ! empty( $this->posts ) ){ return $this->posts; } } return []; } /** * Merge posts */ protected function merge(){ if( ! empty ( $this->endpoints ) ){ /** @var Endpoint $endpoint */ foreach ( $this->endpoints as $endpoint ){ $this->posts[] = $endpoint->get_posts( $this->page ); } $this->posts = $this->sort( $this->posts ); } //prevent recursion in $this->get_posts() $this->looped = true; } /** * Sort posts by date * * @param array $data * * @return array */ protected function sort( array $data ){ usort( $data, function ( $a, $b ) { return strtotime( $a->date ) - strtotime( $b->date ); } ); $data = array_reverse( $data ); return $data; } }
This class takes the current page as an argument for its constructor. Then you can add multiple endpoints to it using the add_endpoint() method. The get_posts() method merges and sorts all of the posts and returns them in one array.
I will leave it up to you as to what you will do with that array. You could loop through them and create HTML markup. Another option is to create a REST API endpoint that returns that array. That way your client could make one query instead of two. If that client used a client-side cache, like I showed in my last article, then you would have in-browser, per-user and server-side caching. Here is an example of how you would use these classes together:
<?php $caldera = new Endpoint( 'https://CalderaWP.com/wp-json/wp/v2/posts' ); $ingot = new Endpoint( 'https://IngotHQ.com/wp-json/wp/v2/posts' ); $multi = new MultiBlog( 1 ); $posts = $multi->get_posts();
Take It Further
In this article, I’ve given you a starting point for combining data from multiple WordPress sites using server-side caching to improve performance. I would encourage you to customize this to fit your needs and solve your own problems.
Also, keep in mind that this code is almost not dependent on WordPress. If you replaced the WordPress HTTP API with Guzzle or Requests, and swapped out the WordPress Transients API for something like PHP Redis you could build a front-end in any PHP framework that uses one or more WordPress sites as its CMS.
No Comments