How We Solved the WordPress Multisite Context Switching Issue
Introduction
WordPress Multisite offers powerful capabilities for managing multiple sites within a single WordPress installation. When you add multilingual content management to the mix, you’re building a sophisticated system that can serve global audiences. However, with great power comes great complexity—and we discovered this firsthand when implementing a custom translation system inspired by Umbraco’s content structure.
In this post, I’ll walk you through how we built a multilingual WordPress ecosystem, the critical bug we encountered with blog context switching, and the elegant solution that fixed it all.
The Architecture: Multisite for Multilingual Content
The Setup
Our WordPress Multisite network contains multiple subsites, each dedicated to a specific language:
- Main site: English (blog.local)
- Subsite 1: Turkish (blog.local/tr/)
- Subsite 2: German (blog.local/de/)
- Subsite 3: Arabic (blog.local/ar/)
- Subsite 4: Azerbaijani (blog.local/az/)
- Subsite 5: Russian (blog.local/ru/)
Each subsite operates with its own database tables, content, and administrative interface. This architecture ensures complete content isolation while allowing for centralized management through a custom translation layer.
Why Custom Over Plugins?
You might wonder why we didn’t use established plugins like Polylang or WPML. While these are excellent solutions, they come with:
- Significant performance overhead
- Feature bloat we didn’t need
- Less granular control over the translation workflow
- Higher complexity for customization
We opted for a lean, purpose-built solution that handles exactly what we needed—nothing more, nothing less.
The Translation System: Metadata and Relationships
The Three Pillars of Translation
Every post in our system carries three critical pieces of metadata:
_post_language: 'tr_TR' // Language code (en_US, tr_TR, de_DE, etc.)
_translation_group: '1868' // Group identifier (typically the original post ID)
_translation_source: '1868' // Source post ID for reference
Understanding Translation Groups
A translation group is the glue that binds all language versions of a single piece of content together. Here’s how it works:
Original English Post:
- Post ID: 1868
- Language: en_US
- Translation Group: 1868
- Translation Source: null (it’s the original)
Turkish Translation:
- Post ID: 1920
- Language: tr_TR
- Translation Group: 1868 (linked to the original)
- Translation Source: 1868 (created from original)
German Translation:
- Post ID: 1925
- Language: de_DE
- Translation Group: 1868 (linked to the original)
- Translation Source: 1868 (created from original)
Even though these posts exist in different WordPress sites with different database tables, they’re semantically linked through the translation group metadata.
The User Experience: Admin Features
1. Language Column in Post Lists
The post list in WordPress Admin displays a language badge for every post, making it immediately clear which language each post is in:
Post Title | Language | Date
Getting Started with React | EN | 2025-01-15
React ile Başlarken | TR | 2025-01-15
Erste Schritte mit React | DE | 2025-01-15
2. Row Action: “Add Translation”
Below each post, a row action link appears showing available languages for translation:
Edit | Quick Edit | Trash | View | + Add Translation (5)
Clicking this link opens a dialog to select which language to translate into. The system automatically:
- Switches to the selected language’s subsite
- Creates a draft post with copied content
- Pre-fills title, body content, categories, and tags
- Establishes the translation relationship through metadata
3. Translation Meta Box
When editing a post, a dedicated meta box displays all available translations:
Available Translations
🇹🇷 Türkçe: React ile Başlarken [Edit]
🇩🇪 Deutsch: Erste Schritte mit React [Edit]
🇸🇦 العربية: البدء مع React [Edit]
🇦🇿 Azərbaycanca: React ilə Başlamaq [Edit]
🇷🇺 Русский: Начало работы с React [Edit]
Each translation is clickable and directs admins to the correct language subsite’s edit page.
The Technical Implementation
Fetching All Translations for a Post
The core of the system is a function that retrieves all language versions of a specific post:
function get_translation_group_posts($post_id) {
global $wpdb;
// Get the translation group ID
$group_id = get_post_meta($post_id, '_translation_group', true);
if (!$group_id) {
$group_id = $post_id; // If not set, use the post ID itself
}
// Get all sites in the network
$sites = get_sites([
'public' => 1,
'deleted' => 0,
'spam' => 0,
]);
$translations = [];
foreach ($sites as $site) {
switch_to_blog($site->blog_id);
// Query for posts in this translation group
$translated_posts = get_posts([
'meta_query' => [
[
'key' => '_translation_group',
'value' => $group_id,
]
],
'posts_per_page' => -1,
]);
// Collect results
foreach ($translated_posts as $post) {
$language = get_post_meta($post->ID, '_post_language', true);
$translations[$language] = [
'post_id' => $post->ID,
'title' => $post->post_title,
'language' => $language,
'site_id' => $site->blog_id,
'edit_url' => get_edit_post_link($post->ID),
];
}
restore_current_blog();
}
return $translations;
}
This function:
- Identifies the translation group for the current post
- Loops through every site in the multisite network
- Temporarily switches context to each site
- Queries for posts belonging to the same translation group
- Collects metadata and edit URLs for each translation
- Returns to the original site context
The Multisite Database Structure
When you switch between sites, WordPress changes which database tables it queries:
Site 1 (English):
- wp_posts
- wp_postmeta
- wp_term_relationships
Site 2 (Turkish):
- wp_2_posts
- wp_2_postmeta
- wp_2_term_relationships
Site 3 (German):
- wp_3_posts
- wp_3_postmeta
- wp_3_term_relationships
The switch_to_blog() function manages this table switching automatically.
The Bug: Context Stack Corruption
The Problem
After implementing the translation system, we noticed something strange: in the post admin list, only the first post in the table had the row action buttons (Edit, Quick Edit, Trash, etc.). Posts 2, 3, 4, and beyond were missing these critical action buttons entirely.
This was a severe UX issue that made managing translated posts nearly impossible.
The Root Cause
The culprit was in the get_translation_group_posts() function. Here’s the problematic code:
foreach ($sites as $site) {
switch_to_blog($site->blog_id); // Switch to site context
// Query the database for translations
$translated_posts = get_posts([
'meta_query' => [
[
'key' => '_translation_group',
'value' => $group_id,
]
],
]);
// Process the results...
// ❌ PROBLEM: restore_current_blog() is NOT called here!
}
restore_current_blog(); // ❌ Only called ONCE after the loop ends
WordPress’s switch_to_blog() and restore_current_blog() functions operate like a stack:
Initial state: Blog 1
switch_to_blog(2) → Stack: [1, 2] (at Blog 2)
switch_to_blog(3) → Stack: [1, 2, 3] (at Blog 3)
switch_to_blog(4) → Stack: [1, 2, 3, 4] (at Blog 4)
switch_to_blog(5) → Stack: [1, 2, 3, 4, 5] (at Blog 5)
switch_to_blog(6) → Stack: [1, 2, 3, 4, 5, 6] (at Blog 6)
restore_current_blog() → Stack: [1, 2, 3, 4, 5] (at Blog 5)
In our broken code with 6 sites, we called switch_to_blog() six times but only restore_current_blog() once. This left us three sites deep in the context stack!
Why Only the First Post Had Action Buttons
WordPress generates row action buttons using functions that depend on the current blog context. When WordPress was rendering the post list:
- 1st post: Generated while still in Blog 1 context ✅ (buttons appear)
- 2nd post: Generated after first
switch_to_blog()without restoration ❌ (wrong context) - 3rd post: Generated after second
switch_to_blog()without restoration ❌ (worse context) - 4th+ posts: Increasingly corrupted context ❌ (no buttons)
The further down the list you went, the deeper in the context stack you were, and the less likely WordPress could correctly generate the administrative UI.
The Solution: Restore Inside the Loop
The fix is surprisingly simple but critical:
foreach ($sites as $site) {
switch_to_blog($site->blog_id); // Switch to site context
// Query the database for translations
$translated_posts = get_posts([
'meta_query' => [
[
'key' => '_translation_group',
'value' => $group_id,
]
],
]);
// Process the results...
restore_current_blog(); // ✅ SOLUTION: Return to original context immediately
}
// We're back at the original blog context
Now the flow for each iteration is:
- Switch to blog context
- Execute database queries
- Collect data
- Immediately restore to original context
- Move to next iteration
This keeps the context stack clean with a maximum depth of 1, ensuring WordPress’s administrative functions always have access to the correct blog context.
Complete Fixed Implementation
Here’s the corrected function in its entirety:
function get_translation_group_posts($post_id) {
global $wpdb;
// Determine the translation group
$group_id = get_post_meta($post_id, '_translation_group', true);
if (!$group_id) {
$group_id = $post_id;
}
// Retrieve all active sites
$sites = get_sites([
'public' => 1,
'deleted' => 0,
'spam' => 0,
]);
$translations = [];
// ✅ Iterate through each site with proper context management
foreach ($sites as $site) {
switch_to_blog($site->blog_id);
try {
$translated_posts = get_posts([
'meta_query' => [
[
'key' => '_translation_group',
'value' => $group_id,
]
],
'posts_per_page' => -1,
]);
foreach ($translated_posts as $post) {
$language = get_post_meta($post->ID, '_post_language', true);
$translations[$language] = [
'post_id' => $post->ID,
'title' => $post->post_title,
'language' => $language,
'site_id' => $site->blog_id,
'edit_url' => get_edit_post_link($post->ID),
];
}
} finally {
restore_current_blog(); // ✅ Always restore, even if errors occur
}
}
return $translations;
}
Notice the addition of a try...finally block. This ensures we return to the original blog context even if an error occurs during database queries—preventing context corruption from cascading through multiple iterations.
Key Lessons Learned
WordPress Multisite Context Management Best Practices
- Always restore immediately after switching
switch_to_blog($blog_id); // Do work restore_current_blog(); - Use try…finally for safety
try { switch_to_blog($blog_id); // Do work } finally { restore_current_blog(); } - Never nest switches without restoration
// ❌ BAD switch_to_blog(2); switch_to_blog(3); restore_current_blog(); // ✅ GOOD switch_to_blog(2); restore_current_blog(); switch_to_blog(3); restore_current_blog(); - Be aware of context-dependent functions Many WordPress functions depend on the current blog context:
get_edit_post_link()get_admin_url()wp_get_attachment_url()get_permalink()- Row action callbacks
- Capability checks
Debugging Multisite Issues
When you encounter strange behavior in WordPress Multisite:
- Check if multiple
switch_to_blog()calls are being made - Verify that each
switch_to_blog()is paired with arestore_current_blog() - Look for exceptions or early returns that might skip restoration
- Use
get_current_blog_id()to debug context state - Add logging to track context switches during development
Inspiration from Other CMS Platforms
The Umbraco-inspired architecture we used treats content relationships as first-class entities through metadata. This is a powerful pattern that encourages:
- Explicit relationships: Metadata makes content relationships clear and queryable
- Flexible structure: No rigid hierarchies; relationships can be as complex as needed
- Batch operations: Querying all translations of a post becomes straightforward
Other CMS platforms like Sitecore, Contentful, and Strapi use similar metadata-driven approaches for managing content relationships across different contexts.
Conclusion
Building a multilingual WordPress Multisite system is complex, but the benefits—centralized management, language isolation, and powerful translation workflows—are worth it. The key is understanding how WordPress manages context across sites and being meticulous about cleaning up after yourself.
The bug we encountered and fixed is a subtle one that could easily trap developers unfamiliar with WordPress Multisite’s stack-based context system. By implementing proper context restoration patterns and using defensive programming techniques like try...finally blocks, you can build robust, reliable multilingual systems on top of WordPress.
If you’re building similar systems, I’d recommend:
- Always use
try...finallyfor context switching - Add logging/debugging to visualize context state
- Write tests that verify context isolation
- Document your metadata schema clearly
- Consider the performance implications of querying multiple sites
Happy multilingual WordPress building!
Discussion Questions
- Have you built multilingual WordPress systems? How did you approach it?
- What other WordPress Multisite gotchas have you encountered?
- Would you use a plugin or build custom like we did?
Share your thoughts in the comments below!