{"id":382697,"date":"2024-11-27T09:09:14","date_gmt":"2024-11-27T16:09:14","guid":{"rendered":"https:\/\/css-tricks.com\/?p=382697"},"modified":"2024-11-27T09:09:20","modified_gmt":"2024-11-27T16:09:20","slug":"wordpress-multi-multisite-a-case-study","status":"publish","type":"post","link":"https:\/\/css-tricks.com\/wordpress-multi-multisite-a-case-study\/","title":{"rendered":"WordPress Multi-Multisite: A Case Study"},"content":{"rendered":"\n
The mission:<\/strong> Provide a dashboard within the WordPress admin area for browsing Google Analytics data for all your blogs.<\/p>\n\n\n\n The catch?<\/strong> You\u2019ve got about 900 live blogs, spread across about 25 WordPress multisite instances. Some instances have just one blog, others have as many as 250. In other words, what you need is to compress a data set that normally takes a very long time to compile into a single user-friendly screen.<\/p>\n\n\n\n\n\n\n\n The implementation details are entirely up to you, but the final result should look like this Figma comp:<\/p>\n\n\n\n I want to walk you through my approach and some of the interesting challenges I faced coming up with it, as well as the occasional nitty-gritty detail in between. I\u2019ll cover topics like the WordPress REST API, choosing between a JavaScript or PHP approach, rate\/time limits in production web environments, security, custom database design \u2014 and even a touch of AI. But first, a little orientation.<\/p>\n\n\n We\u2019re about to cover a lot of ground, so it\u2019s worth spending a couple of moments reviewing some key terms we\u2019ll be using throughout this post.<\/p>\n\n\n WordPress Multisite<\/a> is a feature of WordPress core \u2014 no plugins required \u2014 whereby you can run multiple blogs (or websites, or stores, or what have you) from a single WordPress installation. All the blogs share the same WordPress core files, wp-content folder, and MySQL database. However, each blog gets its own folder within wp-content\/uploads for its uploaded media, and its own set of database tables for its posts, categories, options, etc. Users can be members of some or all blogs within the multisite installation.<\/p>\n\n\n It\u2019s just a nickname for managing multiple instances of WordPress multisite. It can get messy to have different customers share one multisite instance, so I prefer to break it up so that each customer has their own multisite, but they can have many blogs within their multisite.<\/p>\n\n\n It\u2019s apparently possible to run multiple instances of WordPress multisite against the same WordPress core installation. I\u2019ve never looked into this, but I recall hearing about it over the years. I\u2019ve heard the term \u201cNetwork of Networks\u201d and I like it, but that is not the scenario I\u2019m covering in this article.<\/p>\n\n\n You betcha! And people read them, too. You\u2019re reading one right now. Hence, the need for a robust analytics solution. But this article could just as easily be about any sort of WordPress site. I happen to be dealing with blogs, and the word \u201cblog\u201d is a concise way to express \u201ca subsite within a WordPress multisite instance\u201d.<\/p>\n\n\n\n One more thing: In this article, I\u2019ll use the term dashboard site<\/strong> to refer to the site from which I observe the compiled analytics data. I\u2019ll use the term client sites<\/strong> to refer to the 25 multisites I pull data from.<\/p>\n\n\n My strategy was to write one WordPress plugin that is installed on all 25 client sites, as well as on the dashboard site. The plugin serves two purposes:<\/p>\n\n\n\n The WordPress REST API is my favorite part of WordPress. Out of the box, WordPress exposes default WordPress stuff<\/em> like posts, authors, comments, media files, etc., via the WordPress REST API. You can see an example of this by navigating to What\u2019s so great about this? WordPress ships with everything developers need to extend the WordPress REST API and publish custom endpoints<\/a>. Exposing data via an API endpoint is a fantastic way to share it with other websites that need to consume it, and that\u2019s exactly what I did:<\/p>\n\n\n\n We don\u2019t need to get into every endpoint’s details, but I want to highlight one thing. First, I provided a function that returns all my endpoints in an array. Next, I wrote a function to loop through the array and register each array member as a WordPress REST API endpoint. Rather than doing both steps in one function, this decoupling allows me to easily retrieve the array of endpoints in other parts of my plugin to do other interesting things with them, such as exposing them to JavaScript. More on that shortly.<\/p>\n\n\n\n Once registered, the custom API endpoints are observable in an ordinary web browser like in the example above, or via purpose-built tools for API work, such as Postman<\/a>:<\/p>\n\n\n\n I tend to prefer writing applications in PHP whenever possible, as opposed to JavaScript, and executing logic on the server, as nature intended, rather than in the browser. So, what would that look like on this project?<\/p>\n\n\n\n Unfortunately, this strategy falls apart for a couple of reasons:<\/p>\n\n\n\n Damn. I had no choice but to swallow hard and commit to writing the application logic in JavaScript. Not my favorite, but an eerily elegant solution for this case:<\/p>\n\n\n\n Holy cow, it just went from this:<\/p>\n\n\n\n To this:<\/p>\n\n\n\n That is, in theory. In practice, two factors enforce a delay:<\/p>\n\n\n\n With these limitations factored in, I found that it takes about 170 seconds to scrape all 900 blogs. This is acceptable because I cache the results, meaning the user only has to wait once at the start of each work session.<\/p>\n\n\n\n The result of all this madness \u2014 this incredible barrage of Ajax calls, is just plain fun to watch:<\/p>\n\n\n\n
Let\u2019s define some terms<\/h3>\n\n\n
What is WordPress multisite?<\/h4>\n\n\n
What is WordPress multi-<\/em>multisite?<\/h4>\n\n\n
So that\u2019s different from a \u201cNetwork of Networks\u201d?<\/h4>\n\n\n
Why do you keep saying \u201cblogs\u201d? Do people still blog?<\/h4>\n\n\n
My implementation<\/h3>\n\n\n
\n
The WordPress REST API is the Backbone<\/h4>\n\n\n
\/wp-json<\/code> from any WordPress site, including CSS-Tricks<\/a>. Here\u2019s the REST API root for the WordPress Developer Resources site<\/a>:<\/p>\n\n\n\n
\n Open the code <\/summary>\n \n\n
\n<?php\n\n[...]\n\nfunction register(\\WP_REST_Server $server) {\n $endpoints = $this->get();\n\n foreach ($endpoints as $endpoint_slug => $endpoint) {\n register_rest_route(\n $endpoint['namespace'],\n $endpoint['route'],\n $endpoint['args']\n );\n }\n}\n\nfunction get() {\n\n $version = 'v1';\n\n return array(\n \n 'empty_db' => array(\n 'namespace' => 'LXB_DBA\/' . $version,\n 'route' => '\/empty_db',\n 'args' => array(\n 'methods' => array( 'DELETE' ),\n 'callback' => array($this, 'empty_db_cb'),\n 'permission_callback' => array( $this, 'is_admin' ),\n ),\n ),\n\n 'get_blogs' => array(\n 'namespace' => 'LXB_DBA\/' . $version,\n 'route' => '\/get_blogs',\n 'args' => array(\n 'methods' => array('GET', 'OPTIONS'),\n 'callback' => array($this, 'get_blogs_cb'),\n 'permission_callback' => array($this, 'is_dba'),\n ),\n ),\n\n 'insert_blogs' => array(\n 'namespace' => 'LXB_DBA\/' . $version,\n 'route' => '\/insert_blogs',\n 'args' => array(\n 'methods' => array( 'POST' ),\n 'callback' => array($this, 'insert_blogs_cb'),\n 'permission_callback' => array( $this, 'is_admin' ),\n ),\n ),\n\n 'get_blogs_from_db' => array(\n 'namespace' => 'LXB_DBA\/' . $version,\n 'route' => '\/get_blogs_from_db',\n 'args' => array(\n 'methods' => array( 'GET' ),\n 'callback' => array($this, 'get_blogs_from_db_cb'),\n 'permission_callback' => array($this, 'is_admin'),\n ),\n ), \n\n 'get_blog_details' => array(\n 'namespace' => 'LXB_DBA\/' . $version,\n 'route' => '\/get_blog_details',\n 'args' => array(\n 'methods' => array( 'GET' ),\n 'callback' => array($this, 'get_blog_details_cb'),\n 'permission_callback' => array($this, 'is_dba'),\n ),\n ), \n\n 'update_blogs' => array(\n 'namespace' => 'LXB_DBA\/' . $version,\n 'route' => '\/update_blogs',\n 'args' => array(\n 'methods' => array( 'PATCH' ),\n 'callback' => array($this, 'update_blogs_cb'),\n 'permission_callback' => array($this, 'is_admin'),\n ),\n ), \n\n );\n}<\/code><\/pre>\n\n\n<\/details>\n\n\n
<\/figure>\n\n\nPHP vs. JavaScript<\/h4>\n\n\n
\n
\n
\n
( 1 second per Multisite * 25 installs ) + ( 1 second per blog * 900 blogs ) = roughly 925 seconds to scrape all the data.<\/code><\/pre>\n\n\n\n1 second for all the Multisites at once + 1 second for all 900 blogs at once = roughly 2 seconds to scrape all the data.<\/code><\/pre>\n\n\n\n\n
\n Open the code <\/summary>\n \n\n
async function getBlogsDetails(blogs) {\n let promises = [];\n\n \/\/ Iterate and set timeouts to stagger requests by 100ms each\n blogs.forEach((blog, index) => {\n if (typeof blog.url === 'undefined') {\n return;\n }\n\n let id = blog.id;\n const url = blog.url + '\/' + blogDetailsEnpointPath + '?uncache=' + getRandomInt();\n\n \/\/ Create a promise that resolves after 150ms delay per blog index\n const delayedPromise = new Promise(resolve => {\n setTimeout(async () => {\n try {\n const blogResult = await fetchBlogDetails(url, id);\n \n if( typeof blogResult.urls == 'undefined' ) {\n console.error( url, id, blogResult );\n\n } else if( ! blogResult.urls ) {\n console.error( blogResult );\n \n \n } else if( blogResult.urls.length == 0 ) {\n console.error( blogResult );\n \n } else {\n console.log( blogResult );\n }\n \n resolve(blogResult);\n } catch (error) {\n console.error(`Error fetching details for blog ID ${id}:`, error);\n resolve(null); \/\/ Resolve with null to handle errors gracefully\n }\n }, index * 150); \/\/ Offset each request by 100ms\n });\n\n promises.push(delayedPromise);\n});\n\n \/\/ Wait for all requests to complete\n const blogsResults = await Promise.all(promises);\n\n \/\/ Filter out any null results in case of caught errors\n return blogsResults.filter(result => result !== null);\n}<\/code><\/pre>\n\n\n<\/details>\n\n\n