---
title: "Multisite - PerfLocale"
description: "How PerfLocale behaves on a WordPress network: per-site data and settings, network activation, automatic provisioning of new sites, URL modes, and site deletion."
canonical: "https://perflocale.com/docs/multisite/"
source: "https://perflocale.com/docs/multisite/"
format: "markdown"
---
# Multisite

PerfLocale runs on a WordPress network, and everything it stores is **per site**. There is no shared language list, no shared settings blob and no network-level translation store: site 2 and site 7 each get their own tables, their own languages, their own strings and their own background-job queue. What the plugin adds for networks is lifecycle plumbing — provisioning a site when it is created, sweeping every site when the plugin is network-deactivated, purging a site when it is deleted — plus one read-only screen in Network Admin.

This page describes behaviour that is specific to multisite. Everything else in the documentation applies to a subsite exactly as it applies to a single-site install.

## What is per site, what is network-wide

| Data | Scope | Where it lives |
| --- | --- | --- |
| The nine plugin tables | Per site | wp_<id>_perflocale_* — Schema::table() builds names from $wpdb->prefix |
| Languages, translation groups and links | Per site | That site’s own tables |
| Settings | Per site | perflocale_settings in wp_<id>_options |
| Translator role and perflocale_* capabilities | Per site | That site’s roles option, installed by TranslatorRole::install_caps() during activation |
| Background jobs, locks and schedules | Per site | The site’s jobs table, its wp_<id>_options lock rows and its own cron entries |
| Generated translation files (files mode) | Per site | wp_upload_dir() is honoured, so subsites write to uploads/sites/<id>/perflocale/translations/ |
| Addon manifests, schema versions and settings | Per site | Per-site options, one set per subsite |
| Addon bootable-cache generation token | Network | perflocale_bootable_gen in wp_sitemeta |
| Per-user admin preferences (screen options, hidden columns) | Network | wp_usermeta, which switch_to_blog() does not re-scope |

The consequence worth internalising: **configuring one site configures nothing else**. Adding German to site 3 does not add German anywhere, and a string translated on site 3 is not visible to site 4.

## Network activation

When you press **Network Activate**, PerfLocale walks every site on the network being activated and runs the full activation routine on each one, switched in. Per site that means: create the nine tables, seed the default English language if the languages table is empty, install the Translator role and the `perflocale_*` capabilities, and write the default options.

Three details matter on a large or unusual network:

-   **Chunked.** Sites are fetched in blocks rather than loaded all at once. The block size defaults to 100 and is filterable via [`perflocale/activation/chunk_size`](https://perflocale.com/docs/hooks/#perflocale-activation-chunk-size).
-   **Scoped to one network.** The site query passes an explicit `network_id`. `WP_Site_Query` otherwise defaults to “all networks”, and `active_sitewide_plugins` — the option Network Activate actually writes — is per network. Without the scope, activating on one network would provision tables and write schedules on every site of every other network on the installation.
-   **Never-visited subsites still get their crons.** Action Scheduler creates its per-site tables lazily, so the loop forces that schema and then calls `Bootstrap::ensure_recurring_schedules()` for the site. A subsite nobody has ever opened in wp-admin still ends up with its jobs GC and stuck-job watchdog scheduled.

Per-site activation works too. Activating PerfLocale on a single subsite from that site’s own plugins screen runs exactly the same routine for that one site.

## New sites are provisioned automatically

PerfLocale hooks `wp_initialize_site`, so a site created through Network Admin, WP-CLI or `wp_insert_site()` is provisioned as it is created — tables, default language, role and capabilities, and its recurring schedules. The handler runs late (priority 200), checks that the plugin is network-active, and then switches into the new site for everything else.

Two conditions gate it, and both are worth knowing:

-   **The plugin must be network-activated.** The handler checks `is_plugin_active_for_network()` and returns immediately if PerfLocale is not network-active. On a network where PerfLocale is activated site by site, a brand-new site gets nothing until someone activates the plugin on it — which is the moment its tables are created.
-   **A conflicting multilingual plugin stops it.** See below.

Provisioning failures here are logged rather than fatal. The ordinary activation path calls `wp_die()` when a table cannot be created, which on this path would kill the network admin’s site-creation request half way through and leave a partly-built row in `wp_blogs`. Instead the failure goes to the PHP error log, and because the activator never stamps `perflocale_db_version` when a table is missing, the schema step in `Migrator::maybe_migrate()` retries the table creation on the new site’s next admin, REST or WP-CLI request — and records the version only once every table exists.

## Conflicting multilingual plugins

PerfLocale refuses to boot while WPML, Polylang or TranslatePress is active: `Bootstrap::init()` returns early and prints an admin notice naming the other plugin. On multisite the detection reads the site’s own `active_plugins` _and_ merges in the network’s `active_sitewide_plugins`, so a network-activated competitor stops PerfLocale from booting across the whole network, not just on the site you are looking at.

The site-creation handler carries its own version of the check before provisioning — the three plugins’ version constants (`ICL_SITEPRESS_VERSION`, `POLYLANG_VERSION`, `TRP_PLUGIN_VERSION`) plus the new site’s own `active_plugins` option — so a site that already has one of those plugins active never gets PerfLocale tables written into it.

## What Network Admin gives you

One screen. On multisite PerfLocale registers a **PerfLocale** menu in Network Admin (page slug `perflocale-network-jobs`) behind the `manage_network_options` capability, so super admins see it and regular site administrators do not. It renders a single read-only table — the _Network Jobs Overview_ — which switches into each site in turn and reports:

-   the site’s ID, domain and path;
-   how many of its jobs are queued or running, and how many have failed;
-   which background-job engine that site last recorded;
-   how long ago its lock cleanup and its jobs GC last ran;
-   a link straight through to that site’s own **PerfLocale → Jobs** screen.

It is scoped to the network you are administering and capped at the first 100 of its sites; when the cap is hit the screen says so and gives the total. On a network larger than that, use the per-site Jobs screen or WP-CLI for the remainder.

Everything else — settings, languages, translations, strings, addons — is per site by design and has no network-level screen. There is nothing to aggregate, because there is no shared state to aggregate.

## Background jobs and scheduling

The whole background-jobs system is per site. Each subsite has its own `wp_<id>_perflocale_jobs` table, its own settings for processing mode and engine, and its own Jobs screen. On top of that, three pieces of plumbing exist purely for networks:

-   **Jobs record the site that dispatched them.** Every row stores `blog_id`, and the dispatcher attaches the same value to the worker’s arguments as a `__perflocale_blog_id` sentinel. The worker strips the sentinel and calls `switch_to_blog()` before it reads any job state, then restores. Both bundled runners normally fire in the right site’s context already; the sentinel means a queue runner invoked from Network Admin, or a custom runner supplied through [`perflocale/jobs/runner`](https://perflocale.com/docs/hooks/#perflocale-jobs-runner), cannot silently read another site’s jobs table.
-   **Recurring tasks carry their site id.** The daily jobs GC, the hourly stuck-job watchdog and the weekly GC for the machine-translation usage counters are all scheduled with the current site id as an argument, and their handlers switch into that site before running. That is what stops one site’s GC being the only GC that ever runs on the network.
-   **Locks are namespaced by site.** The in-process ownership map for job and type locks is keyed `<blog_id>:<lock option name>`, and the lock rows themselves live in each site’s own options table — the same lock name on two subsites cannot collide.

See [Background Jobs](https://perflocale.com/docs/background-jobs/#multisite) for the queue itself.

## URL modes on a network

This is the one setting that can break a network, and it breaks it upstream of the plugin.

On multisite, WordPress resolves the requested hostname against the network’s site list in `ms-settings.php` **before a single plugin file loads**. If you choose `subdomain` or `domain` URL mode and the hostnames PerfLocale generates — `de.example.com`, `example.de` — are not registered as sites in the network (or mapped by a domain-mapping plugin), a visitor following a translated link gets core’s “site does not exist” screen. No plugin code can intercept that, because no plugin code has run.

PerfLocale checks this for real rather than pattern-matching the setting. It builds each hostname the way the URL converter would, deduplicates them, and resolves each one with `get_site_by_path()` — the same call `ms-settings.php` makes, filters included, so a domain-mapped hostname reports resolvable here for the same reason it resolves in a real request. A network where the operator has done that work reports good.

The check surfaces in two places:

-   **Tools → Site Health**, as the `perflocale_multisite_hosts` test (“PerfLocale multisite language hosts”), with a link to **Sites → Add New** when hostnames are unresolved.
-   **PerfLocale → Settings → URL & Routing**, as an error notice on the tab that owns the setting — because that is where the operator actually makes the mistake, and Site Health only speaks to whoever opens it.

Both run the same code. It costs no queries on a single site, and none in `subdirectory` or `query` mode: those keep this site’s hostname, so there is nothing for the network to fail to resolve, and the check returns before its first lookup. In the two host-rewriting modes it performs one cached site query per distinct hostname, capped at 20 per run so the scheduled health check stays cheap. When the cap bites, the report names the hostnames it checked and warns that the rest were not verified.

**Practical advice.** On a network, `subdirectory` and `query` mode need no network configuration at all. Pick `subdomain` or `domain` only if you are willing to register every language hostname as a site and point DNS at it.

## WooCommerce Store API on subdirectory children

If you run WooCommerce block Cart or Checkout on a **subdirectory** network, Store API responses on a child site come back in the site’s default language rather than the shopper’s. That is deliberate: resolving the shopper’s language there previously broke the endpoint outright. Subdomain and per-domain networks are unaffected. The full explanation is in the [WooCommerce guide](https://perflocale.com/docs/woocommerce/#store-api-subdirectory).

## Behaviour under `switch_to_blog()`

Plugins, cron runners and WP-CLI switch sites mid-request all the time. PerfLocale keeps per-request memos, and the ones holding site-specific data either reset on the `switch_blog` action or are keyed by site id, so a stale entry is not served to the wrong site. On a blog switch the plugin drops, among others:

-   the settings memo, so the new site’s `url_mode`, translatable post types and provider choice drive its own routing;
-   the post and term query-filter caches, the translation-group and slug-translation caches, and the media-translation registry;
-   the string-translation memo, whichever of the database-mode or files-mode service is registered;
-   the addon settings cache, the sitemap and schema caches, and the current-language memo used by the template helpers;
-   the plugin’s request-scoped cache layer — only the static layer, since the object cache and transients are already per site in WordPress.

These listeners are registered only when `is_multisite()` is true. On a single site `switch_to_blog()` is a no-op that still fires the action, and plenty of plugins call `switch_to_blog( get_current_blog_id() )` — registering them unconditionally would let those calls wipe live caches mid-request.

Two related guards are worth knowing about. During site creation WordPress sets `wp_installing()` for the whole run, and PerfLocale skips URL modification entirely while it is set, so nothing queries tables that do not exist yet. The language repository additionally probes for its table before its bootstrap query, because the blog switch fires before the new site’s tables are created.

## Deactivation

Every piece of deactivation cleanup PerfLocale performs is per site — rewrite rules, the flush flag, the Translator role, the plugin caches, and the pending events in both scheduling back-ends. So **Network Deactivate** runs that cleanup on every site of the network, switched in, chunked and network-scoped the same way activation is. A per-site deactivation cleans only that site.

Two behaviours to expect:

-   **Rewrite rules are deleted, not regenerated.** Under `switch_to_blog()` WordPress does not re-initialise the rewrite object, so a hard flush would write the network admin’s permalink structure into the subsite’s options and stick there. Deleting the `rewrite_rules` option instead lets each site rebuild its own correct rules on its next front-end request.
-   **The sweep is time-bounded.** Deactivation hooks fire before WordPress writes the shortened plugin list, so being killed by `max_execution_time` half way through is the worst available outcome: the plugin stays active while the sites already swept have lost their rules, schedules and role. PerfLocale therefore stops when it has spent 70% of PHP’s `max_execution_time`, measured from the start of the request, leaving the remaining sites entirely untouched and internally consistent. It always enters at least one site, and it writes a line to the error log naming how many of how many sites it reached.

If you see that log line, the fix is to re-activate and network-deactivate again from WP-CLI, where `max_execution_time` is `0` and no deadline applies.

## Deleting a site

When a network admin permanently deletes a site, PerfLocale hooks `wp_uninitialize_site` at priority 5 — deliberately ahead of core’s own table-dropper at priority 10, because the purge reads addon manifests out of the site’s options to run addon uninstallers, and after core’s drop that read returns nothing. PerfLocale only ever removes its own tables, options and uploads subdirectory, so running first is safe.

Site deletion **forces a full purge regardless of the “delete data on uninstall” setting**. That preference exists for plugin uninstall, a reversible action where you might want your data back on reinstall. Site deletion is not reversible — WordPress drops the site’s posts, options and attachments along with its row — so keeping `wp_<id>_perflocale_*` tables for a site that no longer exists would leave nothing but orphan data.

One thing is deliberately _not_ swept on this path: user meta. PerfLocale’s per-user admin preferences live in `wp_usermeta`, which is network-global and which `switch_to_blog()` does not re-scope. Sweeping it when a single site is deleted would wipe every user’s preferences on the surviving sites where the plugin is still running. Those keys have no correct per-site deletion, so they are only removed on a full uninstall.

The purge also records which sites it has handled during the request, so core’s subsequent removal of every member from the dying site does not send the job-anonymisation handler at tables that were just dropped.

## Uninstalling from a network

Deleting the plugin removes its files from the whole installation, so `uninstall.php` loops **every site of every network** — unscoped on purpose, since narrowing the query would strand a sibling network’s tables and schedules with no code left on disk to clean them up.

Each site’s own `delete_data_on_uninstall` preference is read inside the loop, so a subsite that opted to keep its data keeps it while its neighbours are purged. Each iteration is wrapped so a fatal on one site cannot leave the following iterations running against the wrong site context. Scheduled-event cleanup always runs, even where data is preserved: the code that backs those events is about to leave disk.

After the loop, the network-scoped keys in `wp_sitemeta` are removed — currently just the addon bootable-cache generation token, which is a regenerable cache value.

## WP-CLI on a network

Every ordinary `wp perflocale` command acts on one site; pass WP-CLI’s `--url=` to choose which. Two subcommands are network-aware:

| Command | What it does |
| --- | --- |
| wp perflocale network-export <file> | Walks every site of the network WP-CLI is pointed at and writes one JSON envelope keyed by site id, marked network: true and stamped with the network URL. Sites where the plugin is not active are skipped unless you pass --include-inactive. Refuses to run outside multisite. |
| wp perflocale network-import <file> | Switches into each matching site and imports its slice. --mode=replace wipes matching data first and prompts for confirmation (honours WP-CLI’s global --yes). --site=<id> restores one source slice into the current site, and is required when loading a network envelope on a single-site install. |

**Site IDs are matched positionally, and they are not stable across networks.** Because translation links and slug translations reference site-specific post and term IDs, importing a slice onto the wrong site is corruption, not a mismatch. So each slice records the site URL it came from, and a slice whose recorded URL does not match the target site’s `home_url()` (compared host plus path, scheme-agnostic) is skipped with a warning. After a domain migration every slice mismatches uniformly while the IDs stay valid — that is what `--force` is for, and it is the operator asserting the IDs are still correct. Sites in the envelope that no longer exist on the receiving network are skipped.

See [the WP-CLI reference](https://perflocale.com/docs/wp-cli/#network-export) for the full command list and [Export & Import](https://perflocale.com/docs/export-import/) for the envelope format.

## Limits, stated plainly

-   **There is no network-wide management UI.** You cannot add a language, change a setting, run a scan or approve a translation for several sites at once from Network Admin. The one network screen is a read-only jobs overview.
-   **That overview is capped at 100 sites** of the network being administered. Beyond that, work per site.
-   **Nothing is inherited.** A new site starts with the default English language and default settings, not with a copy of the main site’s configuration. If you want every site configured the same way, script it — per-site WP-CLI runs, or your own code hooked to site creation.
-   **Automatic provisioning requires network activation.** With per-site activation, a newly created site gets nothing until PerfLocale is activated on it.
-   **Translations do not cross sites.** Translation groups link posts and terms within one site’s tables. There is no cross-site linking, and the network export is a bundle of per-site exports, not a merged dataset.
-   **Network deactivation can stop short** under a small `max_execution_time`, leaving later sites untouched. The error log names the count; re-run from WP-CLI to finish.
-   **Subdomain and domain URL modes need network configuration** that PerfLocale cannot do for you — each language hostname has to exist as a site in the network, or be mapped.

## Related

-   [Background Jobs — multisite behaviour](https://perflocale.com/docs/background-jobs/#multisite)
-   [URL & Routing](https://perflocale.com/docs/url-routing/) — choosing a URL mode
-   [`wp perflocale network-export` / `network-import`](https://perflocale.com/docs/wp-cli/#network-export)
-   [Addon System — writing a multisite-safe addon](https://perflocale.com/docs/addon-system/#example-4-multisite-safe)
-   [Permissions & Roles](https://perflocale.com/docs/permissions/) — the Translator role and `perflocale_*` capabilities
-   [`perflocale/activation/chunk_size` filter](https://perflocale.com/docs/hooks/#perflocale-activation-chunk-size)

[← Back to Docs](https://perflocale.com/docs/)
