Summary
Today's site exporter (inc/site-exporter/) only handles one site at a time. The "Bulk Export Sites" action just iterates wu_exporter_export() per blog and produces N separate ZIPs (inc/site-exporter/class-site-exporter.php:1809-1816) — there is no concept of a single "network bundle" that captures wp_blogs, wp_site, wp_sitemeta, the global users table, network-active plugins, and all selected subsites in one archive.
This issue introduces a new network export flow that produces a single ZIP capable of re-creating an entire multisite network on another host.
Scope (this issue)
- New "Export Network" UI in network admin (Sites bulk action OR new Tools page — implementer's call, see Open Questions).
- Site selection: checklist of all sites in the network, defaulting to all-included, with per-row exclude toggle.
- Main-site reassignment: if
blog_id = 1 (the main site) is excluded, the form must force the user to pick a replacement main site from the included list (radio button). The chosen site becomes the new main site on import.
- Output: a single ZIP
wu-network-export-{date}-{ts}.zip written to wp-content/uploads/wu-site-exports/, containing the network bundle format documented under "Bundle Format" below.
- Re-use the existing per-site export logic (
\TenUp\MU_Migration\Commands\ExportCommand::all()) for each included site under sites/{blog_id}/ — do NOT rewrite per-site export.
- New code path for the network-level artifacts (
network.sql, users.csv, usermeta.csv, network.json manifest).
Out of scope (separate issues)
- Network import (selective import of a network bundle) — Issue B, blocked by this one.
- Self-booting export ZIPs (
index.php installer + readme.txt) — Issue C, blocked by this one.
- Cross-network "merge into existing network" import — explicitly out of scope; the import always treats the bundle as a fresh network restore.
Bundle Format (defines the contract for Issues B and C)
The export ZIP root MUST have:
wu-network-export-{date}-{ts}.zip
├── network.json # manifest (see schema below)
├── users.csv # all wp_users rows (global)
├── usermeta.csv # all wp_usermeta rows
├── network.sql # dump of wp_blogs, wp_site, wp_sitemeta,
│ # wp_signups, wp_registration_log,
│ # wp_blogmeta (if BerlinDB installed)
├── sites/
│ ├── {blog_id}/ # one mu-migration-format bundle per site
│ │ ├── *.json # site meta (existing format)
│ │ ├── *.csv # users (existing format, per-blog)
│ │ ├── *.sql # blog tables (existing format)
│ │ └── wp-content/ # only if uploads/themes/plugins toggle on
│ └── ...
└── wp-content/ # network-shared assets (only if toggled)
├── plugins/ # network-active plugins
├── themes/ # network-installed themes
├── mu-plugins/ # always include if present
└── uploads/ # only the network-root upload dir
# (per-site uploads live under sites/*)
network.json schema (minimum required fields):
{
"format_version": 1,
"exported_at": "2026-05-06T12:00:00Z",
"exported_by_plugin_version": "2.5.x",
"source": {
"url": "https://source.example.com",
"is_subdomain_install": true,
"main_site_blog_id": 1,
"db_prefix": "wp_",
"wp_version": "7.0",
"php_version": "8.2"
},
"designated_main_site_blog_id": 1,
"included_blog_ids": [1, 3, 5, 7],
"excluded_blog_ids": [2, 4],
"network_active_plugins": ["plugin-a/plugin-a.php"],
"sitemeta_keys_restored": ["site_admins", "registration", "..."],
"options": {
"include_plugins": true,
"include_themes": true,
"include_uploads": true,
"include_mu_plugins": true
}
}
format_version: 1 — bump on any backwards-incompatible bundle change. Importer (Issue B) will refuse unknown versions.
Files to modify
| Path |
What |
inc/site-exporter/class-site-exporter.php |
Add register_network_export_form(), render_network_export_modal(), handle_network_export_modal(); add the entry point (network admin Tools menu OR Sites-list bulk action). |
inc/site-exporter/class-network-exporter.php (NEW) |
Orchestrator: builds the bundle, calls per-site export N times, writes network.sql / users.csv / usermeta.csv / network.json, packages the ZIP. |
inc/functions/exporter.php |
Add wu_exporter_export_network(array $included_blog_ids, int $main_site_blog_id, array $options) — async-capable, mirrors wu_exporter_export() shape. Returns ZIP filename or WP_Error. |
inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php |
Likely no changes — call its all() method as-is per included site. If a refactor is needed to redirect output to sites/{blog_id}/, do it in the new class-network-exporter.php, not here. |
inc/site-exporter/mu-migration/includes/helpers.php |
Possibly extend Helpers\zip() to accept multiple roots — only if needed. Prefer building a staging directory and zipping that. |
tests/WP_Ultimo/Site_Exporter_Test.php |
Add tests for: bundle format, main-site reassignment validation, exclude-all-fails-validation, network.json schema, ZIP layout assertions. |
Use the staging-directory pattern (build everything under a temp dir, then zip the dir) — do NOT try to compose a multi-root ZIP in memory. Helpers\zip() already supports a directory-tree map.
Reference patterns to copy
- Per-site export call shape:
inc/site-exporter/class-site-exporter.php:2032-2088 (handle_site_export()). Copy the try/catch + file_exists() post-check + wu_exporter_save_generation_time() pattern.
- mu-migration export entry:
inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php:397-556 (ExportCommand::all()). The orchestrator must call this once per included blog, redirecting the output filename to {staging}/sites/{blog_id}/site.zip (or unzipping immediately into {staging}/sites/{blog_id}/).
- Form/modal registration:
inc/site-exporter/class-site-exporter.php:1298-1326 (register_forms()) and :1334-1399 (render_export_site_modal()).
- Background async pattern:
inc/functions/exporter.php:29 (wu_exporter_export() with $async = true). Reuse wu_exporter_set_transient() for the pending state.
- Helper functions in mu-migration:
inc/site-exporter/mu-migration/includes/helpers.php — see Helpers\zip(), Helpers\get_db_prefix(), Helpers\maybe_switch_to_blog().
Implementation notes / gotchas
mu-migration export all writes intermediate files to sys_get_temp_dir() (see comment block at class-mu-migration-export.php:461-482). The network export's staging dir should also live under sys_get_temp_dir() to inherit the same writability and CWD-safety properties — do not stage under wp-content/uploads/ directly.
wp_users is global in multisite. Export the whole table once; do NOT call the per-blog users() exporter inside class-mu-migration-export.php for each site (that would produce N copies of the same global users with mismatched usermeta blog-prefixed capability rows).
wp_usermeta capability rows are blog-prefixed (wp_2_capabilities, wp_3_capabilities, …). When a blog is excluded, its capability/usermeta rows MUST also be excluded from usermeta.csv. Filter on the meta_key prefix matching {$wpdb->base_prefix}{blog_id}_* for non-included IDs.
wp_blogs.path and wp_site.domain must reflect the source network as-is. The importer (Issue B) will rewrite these. Do not rewrite at export time.
wp_sitemeta carries site_admins, registration, add_new_users, etc. Whitelist the keys to restore in network.json.sitemeta_keys_restored rather than dumping the whole table — some keys (e.g. recently_edited, transient-like keys) shouldn't propagate. Implementer should propose the whitelist in the PR description and we'll review.
- Main-site reassignment is metadata-only at export time. Only
network.json.designated_main_site_blog_id records the choice — the actual swap happens on import (Issue B). Validation in this issue: refuse to export if included_blog_ids is empty, or if designated_main_site_blog_id is not in included_blog_ids.
- BerlinDB tables (
wp_wu_*) are global Ultimate Multisite tables and SHOULD be included in network.sql. Use \WP_Ultimo\Database\Sites\Site_Schema::get_table_name() etc. to discover them — do not hardcode names. Treat their inclusion as optional but on-by-default.
- Filesize: a network with 50 sites and uploads can be 5+ GB. The progress UI must show overall progress (X of N sites exported) and the background path must be the default (not the on-by-default sync path). Reuse the existing
wu_pending_site_export_* transient pattern.
- Capability for the form:
manage_network (matches existing register_forms() calls).
- Filename collision: include
time() and a random suffix in the ZIP filename to avoid clobbering parallel exports.
Verification
Open questions for the implementer (decide in PR description)
- Entry point: add to existing Sites bulk action ("Export Network") or new Tools-menu page ("Export Network")? Bulk action is faster to ship; Tools page is more discoverable. I lean toward a new Tools page (network admin → Tools → Export Network) so the UI has room for the site checklist + main-site reassignment without crowding the Sites list.
- Sitemeta whitelist: propose the exact list of
wp_sitemeta keys to restore (defaults to a hardcoded safe-list with a wu_network_export_sitemeta_keys filter for extension).
- Per-site uploads location: under
sites/{blog_id}/wp-content/uploads/ (mirrors mu-migration's per-site bundle) or hoist to wp-content/uploads/sites/{blog_id}/ at export time (matches the live multisite layout)? Decide based on what makes the importer (Issue B) simpler.
Dependencies / blocks
- Blocks Issue B (network import — selective). Issue B can start design but cannot ship until this issue's bundle format lands.
- Blocks Issue C (self-booting export ZIPs). Issue C is a pure addition once the bundle format is stable.
Summary
Today's site exporter (
inc/site-exporter/) only handles one site at a time. The "Bulk Export Sites" action just iterateswu_exporter_export()per blog and produces N separate ZIPs (inc/site-exporter/class-site-exporter.php:1809-1816) — there is no concept of a single "network bundle" that captureswp_blogs,wp_site,wp_sitemeta, the global users table, network-active plugins, and all selected subsites in one archive.This issue introduces a new network export flow that produces a single ZIP capable of re-creating an entire multisite network on another host.
Scope (this issue)
blog_id = 1(the main site) is excluded, the form must force the user to pick a replacement main site from the included list (radio button). The chosen site becomes the new main site on import.wu-network-export-{date}-{ts}.zipwritten towp-content/uploads/wu-site-exports/, containing the network bundle format documented under "Bundle Format" below.\TenUp\MU_Migration\Commands\ExportCommand::all()) for each included site undersites/{blog_id}/— do NOT rewrite per-site export.network.sql,users.csv,usermeta.csv,network.jsonmanifest).Out of scope (separate issues)
index.phpinstaller +readme.txt) — Issue C, blocked by this one.Bundle Format (defines the contract for Issues B and C)
The export ZIP root MUST have:
network.jsonschema (minimum required fields):{ "format_version": 1, "exported_at": "2026-05-06T12:00:00Z", "exported_by_plugin_version": "2.5.x", "source": { "url": "https://source.example.com", "is_subdomain_install": true, "main_site_blog_id": 1, "db_prefix": "wp_", "wp_version": "7.0", "php_version": "8.2" }, "designated_main_site_blog_id": 1, "included_blog_ids": [1, 3, 5, 7], "excluded_blog_ids": [2, 4], "network_active_plugins": ["plugin-a/plugin-a.php"], "sitemeta_keys_restored": ["site_admins", "registration", "..."], "options": { "include_plugins": true, "include_themes": true, "include_uploads": true, "include_mu_plugins": true } }format_version: 1— bump on any backwards-incompatible bundle change. Importer (Issue B) will refuse unknown versions.Files to modify
inc/site-exporter/class-site-exporter.phpregister_network_export_form(),render_network_export_modal(),handle_network_export_modal(); add the entry point (network admin Tools menu OR Sites-list bulk action).inc/site-exporter/class-network-exporter.php(NEW)network.sql/users.csv/usermeta.csv/network.json, packages the ZIP.inc/functions/exporter.phpwu_exporter_export_network(array $included_blog_ids, int $main_site_blog_id, array $options)— async-capable, mirrorswu_exporter_export()shape. Returns ZIP filename orWP_Error.inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.phpall()method as-is per included site. If a refactor is needed to redirect output tosites/{blog_id}/, do it in the newclass-network-exporter.php, not here.inc/site-exporter/mu-migration/includes/helpers.phpHelpers\zip()to accept multiple roots — only if needed. Prefer building a staging directory and zipping that.tests/WP_Ultimo/Site_Exporter_Test.phpUse the staging-directory pattern (build everything under a temp dir, then zip the dir) — do NOT try to compose a multi-root ZIP in memory.
Helpers\zip()already supports a directory-tree map.Reference patterns to copy
inc/site-exporter/class-site-exporter.php:2032-2088(handle_site_export()). Copy thetry/catch+file_exists()post-check +wu_exporter_save_generation_time()pattern.inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php:397-556(ExportCommand::all()). The orchestrator must call this once per included blog, redirecting the output filename to{staging}/sites/{blog_id}/site.zip(or unzipping immediately into{staging}/sites/{blog_id}/).inc/site-exporter/class-site-exporter.php:1298-1326(register_forms()) and:1334-1399(render_export_site_modal()).inc/functions/exporter.php:29(wu_exporter_export()with$async = true). Reusewu_exporter_set_transient()for the pending state.inc/site-exporter/mu-migration/includes/helpers.php— seeHelpers\zip(),Helpers\get_db_prefix(),Helpers\maybe_switch_to_blog().Implementation notes / gotchas
mu-migration export allwrites intermediate files tosys_get_temp_dir()(see comment block atclass-mu-migration-export.php:461-482). The network export's staging dir should also live undersys_get_temp_dir()to inherit the same writability and CWD-safety properties — do not stage underwp-content/uploads/directly.wp_usersis global in multisite. Export the whole table once; do NOT call the per-blogusers()exporter insideclass-mu-migration-export.phpfor each site (that would produce N copies of the same global users with mismatchedusermetablog-prefixed capability rows).wp_usermetacapability rows are blog-prefixed (wp_2_capabilities,wp_3_capabilities, …). When a blog is excluded, its capability/usermeta rows MUST also be excluded fromusermeta.csv. Filter on themeta_keyprefix matching{$wpdb->base_prefix}{blog_id}_*for non-included IDs.wp_blogs.pathandwp_site.domainmust reflect the source network as-is. The importer (Issue B) will rewrite these. Do not rewrite at export time.wp_sitemetacarriessite_admins,registration,add_new_users, etc. Whitelist the keys to restore innetwork.json.sitemeta_keys_restoredrather than dumping the whole table — some keys (e.g.recently_edited, transient-like keys) shouldn't propagate. Implementer should propose the whitelist in the PR description and we'll review.network.json.designated_main_site_blog_idrecords the choice — the actual swap happens on import (Issue B). Validation in this issue: refuse to export ifincluded_blog_idsis empty, or ifdesignated_main_site_blog_idis not inincluded_blog_ids.wp_wu_*) are global Ultimate Multisite tables and SHOULD be included innetwork.sql. Use\WP_Ultimo\Database\Sites\Site_Schema::get_table_name()etc. to discover them — do not hardcode names. Treat their inclusion as optional but on-by-default.wu_pending_site_export_*transient pattern.manage_network(matches existingregister_forms()calls).time()and a random suffix in the ZIP filename to avoid clobbering parallel exports.Verification
npm run checkpasses (lint + phpstan + phpunit).tests/WP_Ultimo/Site_Exporter_Test.phppass:test_network_export_produces_expected_layout()— assert ZIP root containsnetwork.json,users.csv,usermeta.csv,network.sql,sites/.test_network_export_excludes_unselected_sites()—sites/only contains selected blog IDs.test_network_export_main_site_reassignment_recorded_in_manifest().test_network_export_refuses_empty_included_blog_ids().test_network_export_refuses_main_site_not_in_included().test_network_json_schema_has_required_fields()— at least the keys listed in the schema above.wordpress.local:8080network admin, export a network with 3+ sites. Inspect the ZIP. Verifynetwork.jsonmatches the schema,users.csvrow count equalsSELECT COUNT(*) FROM wp_users,network.sqlcontainswp_blogsrows for only included blog IDs, and per-site bundles undersites/{id}/are valid mu-migration ZIPs (or extracted directories).network.json.designated_main_site_blog_id == 3andincluded_blog_idsdoes not contain 1.Open questions for the implementer (decide in PR description)
wp_sitemetakeys to restore (defaults to a hardcoded safe-list with awu_network_export_sitemeta_keysfilter for extension).sites/{blog_id}/wp-content/uploads/(mirrors mu-migration's per-site bundle) or hoist towp-content/uploads/sites/{blog_id}/at export time (matches the live multisite layout)? Decide based on what makes the importer (Issue B) simpler.Dependencies / blocks