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. - Scoped to one network. The site query passes an explicit
network_id.WP_Site_Queryotherwise defaults to “all networks”, andactive_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_idsentinel. The worker strips the sentinel and callsswitch_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 throughperflocale/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 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_hoststest (“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.
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 therewrite_rulesoption 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_timehalf 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’smax_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 for the full command list and 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
- URL & Routing — choosing a URL mode
wp perflocale network-export/network-import- Addon System — writing a multisite-safe addon
- Permissions & Roles — the Translator role and
perflocale_*capabilities perflocale/activation/chunk_sizefilter