How We Solved the WordPress Multisite Context Switching Issue

Mudos Digital Mudos Digital
10 min read

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:

  1. Identifies the translation group for the current post
  2. Loops through every site in the multisite network
  3. Temporarily switches context to each site
  4. Queries for posts belonging to the same translation group
  5. Collects metadata and edit URLs for each translation
  6. 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:

  1. Switch to blog context
  2. Execute database queries
  3. Collect data
  4. Immediately restore to original context
  5. 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

  1. Always restore immediately after switching switch_to_blog($blog_id); // Do work restore_current_blog();
  2. Use try…finally for safety try { switch_to_blog($blog_id); // Do work } finally { restore_current_blog(); }
  3. 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();
  4. 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:

  1. Check if multiple switch_to_blog() calls are being made
  2. Verify that each switch_to_blog() is paired with a restore_current_blog()
  3. Look for exceptions or early returns that might skip restoration
  4. Use get_current_blog_id() to debug context state
  5. 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...finally for 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!

Share this Post

Let's Build Something Great Together

Have a project in mind or just want to say hello? We'd love to hear from you. Fill out the form and we'll get back to you as soon as possible.

Fast response times
Free project consultation

Or Contact Us Directly