WP Manifestindependent plugin directory
manifest / multisite / site-migrator

Site Migrator

Migrate sites between WordPress Multisite networks via REST API.

by ross-mulcahy · github.com/ross-mulcahy/site-migrator

★ 0stars
0forks

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.zip

Migrate 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

  1. Copy the site-migrator/ directory into wp-content/plugins/ on each Multisite network.
  2. Network Activate the plugin from Network Admin > Plugins.
  3. 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, or Skip if Exists.

Usage

Admin UI

  1. Go to Site Migrator > Migrate.
  2. Select a mapping from the dropdown.
  3. Click Dry Run to validate without making changes, or Run Migration to start.
  4. Monitor progress in the Active Jobs panel with live polling.
  5. 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-Secret HTTP header, validated with hash_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 with hash_equals().
  • All forms use wp_nonce_field() / check_admin_referer().
  • All AJAX handlers check check_ajax_referer() and current_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.