Site Migrator
Migrate sites between WordPress Multisite networks via REST API.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/ross-mulcahy/site-migrator/archive/refs/heads/main.zipMigrate individual sites between WordPress Multisite networks via REST API.
Site Migrator is a network-activated plugin installed on each Multisite network. Networks are connected through a shared-secret handshake configured in the Network Admin UI. Once connected, you can define reusable source-to-destination mappings and run migrations from the admin dashboard or WP-CLI.
Requirements
- WordPress Multisite 5.9+
- PHP 7.4+
- Action Scheduler (recommended for async imports; falls back to wp-cron)
- WP-CLI 2.5+ (optional, for CLI commands)
Installation
- Copy the
site-migrator/directory intowp-content/plugins/on each Multisite network. - Network Activate the plugin from Network Admin > Plugins.
- Navigate to Network Admin > Site Migrator > Settings to configure.
Configuration
All configuration is stored in network options. No wp-config.php constants are required.
1. Set your network identity
Go to Site Migrator > Settings and set:
- Network Label - Human-readable name shown to remote networks.
- Shared Secret - Authenticates inbound requests to this network. Click "Regenerate" to create a random 32-character secret. Stored hashed via
wp_hash().
2. Connect remote networks
In the Connected Networks section, add each remote network:
- Label - A name for the remote network.
- REST Base URL - e.g.
https://network-b.com/wp-json/site-migrator/v1 - Their Shared Secret - The secret configured on the remote network (what you'll send in outbound requests).
Click Add & Verify Connection to save and ping the remote /ping endpoint.
3. Define site mappings
Go to Site Migrator > Site Mappings and create mappings:
- Source Site - A site on this network.
- Destination Network - A connected remote network.
- Destination Domain/Path - Where the site will land on the remote network. Leave blank to use the source's domain/path.
- Conflict Strategy -
Create New Site,Overwrite Existing, orSkip if Exists.
Usage
Admin UI
- Go to Site Migrator > Migrate.
- Select a mapping from the dropdown.
- Click Dry Run to validate without making changes, or Run Migration to start.
- Monitor progress in the Active Jobs panel with live polling.
- View logs inline or check the Migration History table.
WP-CLI
# List configured networks, mappings, or jobs
wp site-migrator list networks
wp site-migrator list mappings
wp site-migrator list jobs
# Export a site to a JSON file
wp site-migrator export 3 --file=site-3.json
# Validate a package without importing (dry run)
wp site-migrator import site-3.json --dry-run
# Import a package synchronously
wp site-migrator import site-3.json
# Run a migration using a saved mapping
wp site-migrator migrate <mapping-id> --secret=<remote-secret>
# Dry-run a remote migration
wp site-migrator migrate <mapping-id> --secret=<remote-secret> --dry-run
# Check job status
wp site-migrator status <job-id>
# View job logs (filterable by level)
wp site-migrator logs <job-id>
wp site-migrator logs <job-id> --level=error --limit=100
# Cancel a running job
wp site-migrator cancel <job-id>
All list/status/logs commands support --format=table|json|csv|yaml.
REST API
All endpoints are under the site-migrator/v1 namespace.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/ping |
Secret | Returns network identity and plugin version |
GET |
/export/{site_id} |
Admin or Secret | Exports a site as a JSON package |
GET |
/export/{site_id}/status |
Admin or Secret | Poll export status |
POST |
/import |
Admin or Secret | Accept a package and queue import |
POST |
/import?dry_run=1 |
Admin or Secret | Validate a package without importing |
GET |
/import/{job_id} |
Admin or Secret | Poll import job progress |
Authentication
- Admin UI requests - Authenticated via
current_user_can('manage_network'). - Network-to-network - Shared secret sent as
X-Site-Migrator-SecretHTTP header, validated withhash_equals()against the stored hash.
What gets migrated
| Content | Exported | Imported | Notes |
|---|---|---|---|
| Posts & Pages | All public types, publish/draft/private |
wp_insert_post() with ID remapping |
Batched 100 at a time |
| Post Meta | All meta except _edit_lock, _edit_last |
add_post_meta() |
Serialized data preserved |
| Custom Post Types | All registered public CPTs | Same as posts | |
| Terms & Taxonomies | All taxonomies with term_meta |
wp_insert_term() with parent resolution |
Two-pass for hierarchy |
| Site Options | Allowlisted keys only | update_option() |
Never exports siteurl, home, secrets |
| Users | Login, email, role | add_user_to_blog() |
Only adds existing network users; does not create new users |
| Media | Manifest with source URLs | wp_remote_get() + media_handle_sideload() |
Re-fetched, never base64 |
| Gutenberg blocks | Block attribute IDs (id, mediaId) |
Regex remap in post content | Second pass after all inserts |
Options allowlist
blogname, blogdescription, template, stylesheet, posts_per_page,
default_category, default_post_format, show_on_front, page_on_front,
page_for_posts, nav_menus, sidebars_widgets, theme_mods_{stylesheet}
Architecture
site-migrator/
├── site-migrator.php # Bootstrap, autoloader, hooks
├── uninstall.php # Cleanup on uninstall
├── includes/
│ ├── API/
│ │ └── class-router.php # REST route registration
│ ├── CLI/
│ │ └── class-command.php # WP-CLI commands
│ ├── Export/
│ │ ├── class-exporter.php # Orchestrates full site export
│ │ ├── class-post-exporter.php
│ │ ├── class-term-exporter.php
│ │ └── class-options-exporter.php
│ ├── Import/
│ │ ├── class-importer.php # Orchestrates import + dry-run validation
│ │ ├── class-post-importer.php
│ │ ├── class-term-importer.php
│ │ ├── class-options-importer.php
│ │ ├── class-media-handler.php
│ │ └── class-queue.php # Action Scheduler / wp-cron queue
│ ├── Util/
│ │ ├── class-id-remapper.php # Old-to-new ID mapping + block remap
│ │ └── class-logger.php # Per-job structured logging
│ └── Admin/
│ ├── class-network-admin.php
│ ├── class-settings-page.php
│ ├── class-site-map-page.php
│ └── class-migrate-page.php
├── admin/
│ ├── views/
│ │ ├── settings.php
│ │ ├── site-map.php
│ │ ├── migrate.php
│ │ └── partials/
│ │ ├── job-status.php
│ │ └── network-row.php
│ └── js/
│ └── admin.js # AJAX polling, UI controls
└── tests/
└── test-post-importer.php # WP_UnitTestCase tests
Data storage
All plugin data is stored in wp_sitemeta (network options):
| Key | Type | Description |
|---|---|---|
site_migrator_network_label |
string |
This network's display name |
site_migrator_shared_secret |
string |
Hashed inbound secret |
site_migrator_networks |
array |
Connected remote networks |
site_migrator_site_maps |
array |
Source-to-destination mappings |
site_migrator_active_jobs |
array |
List of tracked job UUIDs |
site_migrator_import_{job_id} |
array |
Job status and progress |
site_migrator_log_{job_id} |
array |
Job log entries (max 500) |
site_migrator_package_{job_id} |
array |
Temporary package data (deleted after import) |
site_migrator_tracked_job_{job_id} |
array |
Remote job tracking info |
Transients (auto-expiring):
site_migrator_secret_{network_id}- Plaintext secret, 60s TTL, for verification handshake.site_migrator_cancel_{job_id}- Cancellation flag checked between batches.
All data is removed on plugin uninstall via uninstall.php.
Security
- Shared secrets stored hashed via
wp_hash(), validated withhash_equals(). - All forms use
wp_nonce_field()/check_admin_referer(). - All AJAX handlers check
check_ajax_referer()andcurrent_user_can('manage_network'). - Input sanitized with
sanitize_text_field(),absint(),sanitize_url()+wp_unslash(). - Output escaped with
esc_html(),esc_attr(),esc_url(). - No raw SQL queries — uses WordPress API functions throughout.
- No direct filesystem writes — media fetched via
wp_remote_get()and sideloaded.
VIP compatibility
- No direct filesystem access.
- No raw database queries.
- Action Scheduler preferred over wp-cron.
- All site-scoped operations wrapped in
switch_to_blog()/restore_current_blog().
Testing
Tests use WP_UnitTestCase and are located in tests/.
# Run with your WordPress test suite
phpunit --filter Test_Post_Importer
The test suite covers: post insertion, meta handling, term assignment (remapper + slug fallback), parent resolution (both orderings), Gutenberg block ID remapping, progress tracking, cancellation, and error logging.
Known limitations
- JSON only - No WXR/XML format support.
- Multisite-to-multisite only - Does not support single-site installations.
- No plugin-specific custom tables - Only migrates core WordPress data structures.
- No automatic rollback - Failed imports must be cleaned up manually.
- No scheduled/recurring migrations - Migrations are triggered manually.
- Users must exist on the destination network - The plugin adds existing network users to the new site but does not create new user accounts.
License
GPL v2 or later.