Use when planning, sizing, or running a WordPress content migration — WP→WP (multisite consolidation, hosting move, theme replatform) or other-system→WP (Laravel, Drupal, static, proprietary CMS). Triggers: "WP migration", "WordPress migration", "content migration", "migrate to WordPress", "WP to WP migration", "Laravel to WordPress", "Drupal to WordPress", "import posts", "migrate attachments", "media migration", "migration runbook", "migration plugin", and any bulk wp_posts / wp_postmeta / wp_term_* transform.
How this skill is triggered — by the user, by Claude, or both
Slash command
/wp-migration-playbook:wp-migration-playbookThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Cross-project playbook synthesized from real WordPress migrations. Opinionated — these patterns survived production. Other approaches exist; these are the ones that didn't bite.
Cross-project playbook synthesized from real WordPress migrations. Opinionated — these patterns survived production. Other approaches exist; these are the ones that didn't bite.
Scope: Content migrations onto WordPress — WP→WP (multisite consolidation, hosting move, theme replatform) and other-system→WP (Laravel / Drupal / static / proprietary CMS). Not generic data-warehouse migrations.
Note on SQL: Examples use the
wp_table prefix. Substitute the install's real prefix (and per-site prefixwp_<n>_on multisite).
Reach for this skill whenever planning, sizing, or running a WordPress migration:
If a task touches wp_posts / wp_postmeta / wp_term_* in bulk, this is the right reference.
Executing a migration? This skill is organized by topic. For the ordered phase sequence (import → pre-flight → URL rewrite → inventory → redirects → transforms → verify → cutover → recovery), follow
references/runbook.mdand pull the topic sections below as each phase needs them.
You cannot scope or de-risk a WordPress migration without first putting the legacy DB on a table and asking it questions. Most production-grade migration incidents trace back to something nobody measured upfront. Run the discovery queries before writing a single migration script; capture the output in a discovery doc that drives sizing, plugin scope, redirect strategy, and recovery planning.
The full discovery checklist — runnable SQL for volume/cohort sizing, CPTs and taxonomies, users and authorship, block and pattern census, inline references and shortcodes, plugin-stored redirects, the permalink/URL surface, and image inventory — lives in references/pre-flight-sql.md. Import the dump first (wp db cli < dump.sql, see §8) and work against the copy.
| Shape | Approach | Use when |
|---|---|---|
| WP → WP, small (< 10K records) | Custom migration plugin, WP-CLI driven, single dump import + Tier 1–3 transforms | Inventory-driven per-record decisions, mixed dispositions |
| WP → WP, large (> 50K records) | Direct DB import (wp_<site>_* → wp_*) + SQL pipeline + WP-CLI commands for non-trivial transforms | Uniform cohort, multisite consolidation, theme replatform |
| System → WP | Schema discovery → entity grouping → unified CPT modelling → import via WP-CLI | Source schema is not WP-shaped |
Volume drives the choice. ID-preserving direct-DB import scales; programmatic wp_insert_post() per record does not (hooks, revisions, sanitization compound).
The legacy DB dump is also the rollback artifact. Never mutate the source DB; always import a copy and transform it. Re-import = full reset. This invariant lets you iterate aggressively on transforms without fear.
Large dumps (multi-GB) need import tuning — raise max_allowed_packet, dump with --single-transaction, mind innodb_buffer_pool_size. See references/runbook.md (Large imports). On managed hosts you can't tune MySQL; chunk per-table or use host tooling.
Any WP→WP move that changes domain or path needs a database-wide URL rewrite. Never use SQL REPLACE() — it corrupts serialized data (PHP encodes byte lengths). Use wp search-replace --recurse-objects --skip-columns=guid, dry-run first. Full procedure, multisite handling, and gotchas (escaped-slash block URLs, GUID column) in references/search-replace.md.
Consolidating one site out of a multisite network (or merging several) has mechanics a single-site move doesn't:
wp_<n>_ (e.g. wp_5_posts); the network's primary site uses bare wp_. Direct-DB import renames wp_<n>_* → wp_* and reconciles the prefixed capability/role keys — full remap pipeline (table renames, wp_<n>_capabilities → wp_capabilities, network-table drop, config cleanup) in references/runbook.md.wp_users, wp_usermeta with wp_<n>_capabilities keys). Capabilities are per-site meta keys; collapse them to standard roles on the target (see §5).wp_blogs.domain/path and wp_site/wp_sitemeta, not only per-site options — wp search-replace covers options but verify these too.--network / --url=<site> scope WP-CLI to the network or a single subsite.wp_*.Every record needs an explicit disposition before any transform runs. Three classes:
Use a spreadsheet (or any structured external source) keyed by legacy URL. Required columns at minimum:
| Column | Purpose |
|---|---|
Existing URL | Source of truth for the legacy record |
Migration Status | Migrate / Build / Do not migrate |
New URL | Target URL when migrating |
Redirect | Explicit 301 target for DNM rows |
| CPT / template / SEO overrides | Per-record customization the transformer applies |
Import the spreadsheet into a custom DB table (<plugin_prefix>_migration_inventory). Run a separate inventory match step before any transform that joins inventory to legacy wp_posts.ID by URL. Emit unmatched-row counts as a sign-off blocker — every disposition must resolve to a real legacy record. Once matched, every downstream transform is "for each row in inventory, do X."
Skip the spreadsheet. Encode dispositions in code (e.g. "all videos discarded", "shadow CPT X collapsed to taxonomy Y"). Trade-off: less auditable, fewer per-record edge cases to handle.
Distilled pattern for inventory-driven migrations. Transfers to any one-shot migration plugin.
| Tier | Scope | Risk |
|---|---|---|
| Tier 1 | Inventory import + match to legacy DB + read-only audits | None |
| Tier 2 | Redirect map build + bulk-import export | None — output only |
| Tier 3 | Destructive wp_posts / wp_postmeta / wp_term_* transforms | High — requires dump backup |
| Cleanup | Post-launch chrome stripping, residual option clearing, recovery | Medium — gated, idempotent |
--apply. Both modes write to the same audit log so audits filter by intent.<plugin_prefix>_migration_log) with (step, action, legacy_post_id, before_value, after_value, dry_run, notes, logged_at). One row per touched record + one summary row per pass.--apply pass runs, the subcommand queries the log for its own step + action='<task>_completed' + dry_run=0 marker. Present → refuse with a clear error. --force bypasses.A destructive DNM-delete step refuses to run unless the redirect map has been exported and logged. The redirect-invariant becomes a SQL gate, not a process checklist:
$redirects_exported = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$log}
WHERE step = 'redirects'
AND action = 'exported'
AND dry_run = 0"
);
if ( ! $dry_run && $redirects_exported === 0 ) {
return [ 'blocked' => true, ... ];
}
Checklists fail; code gates don't.
Use the same flags across every subcommand so muscle memory transfers:
| Flag | Meaning |
|---|---|
--apply | Mutate. Default = dry-run. |
--force | Bypass the run-once completion gate. |
--limit=<n> | Cap on genuine work, not skips. Skipped (already-done) records do not consume the budget. |
--show-skipped | Render per-row skip detail with reason. |
--analyze | Cohort breakdown without mutating (for recovery commands). |
Counter semantics gotcha: if --limit counts idempotent skips against the cap, batched re-runs burn the entire budget re-checking already-restored records. Increment the work counter only after the cheap-skip check. Full contract in references/cli-flag-conventions.md.
If the legacy schema has several "resource-like" types with mostly-overlapping fields (Blog / Customer Story / eBook / Webinar / Event style, or marketplace catalogs with several similar listings), collapse into a single CPT plus a taxonomy of types. Reasons:
Keep separate CPTs only when schemas genuinely diverge (different field groups, different workflow, different permissions).
WP multisite installs often have "shadow" CPTs that mirror taxonomies (e.g. an author-post CPT mirroring an author taxonomy, used to attach bio/photo/social fields). Migration target: collapse these by moving the metadata fields onto wp_termmeta for the equivalent term. The CPT itself goes away.
Direct UPDATE wp_posts SET post_type='blog' WHERE post_type='resource' AND ID IN (…) is the fastest path for tens of thousands of records. ID-preserving means block-attribute references survive. Log per-row to the audit table for traceability.
When collapsing multiple legacy taxonomies into a smaller set on the new site:
term_relationships row, insert a new row pointing at the target term.wp_term_taxonomy.author taxonomy via plugin filters (Parse.ly, Yoast, AuthorSEO JSON-LD, etc.).If the legacy site uses authors only as a categorization device — no actual login — don't migrate them as wp_users. Migrate them as a taxonomy. WP's user table has password hashes, capabilities, session tokens, application passwords that a content migration doesn't care about.
wp_users.user_email has a unique index. Multisite imports + repeated migrations create duplicate emails. Resolve in a pre-flight SQL pass before any wp_insert_user (query in references/pre-flight-sql.md).
Strategy: keep the earliest registered, suffix-mangle the rest ([email protected]), or merge to a generic editorial user per the disposition above.
Media is where migrations bleed. Every production incident and every multi-day delay tends to trace back to something nobody measured about images upfront. Read this section twice, and run the image-discovery queries in references/pre-flight-sql.md before designing anything.
| Sub-problem | Pattern |
|---|---|
| Files on disk | Cloud sources fetched by URL; local sources copied via rsync / tarball / SCP |
wp_posts.ID preservation | Direct $wpdb->insert(['ID' => $legacy_id, ...]) |
_wp_attached_file postmeta | Relative path under uploads/ |
_wp_attachment_metadata postmeta | wp_generate_attachment_metadata() regenerates sizes from the file on disk |
| Inline references | wp-image-N, "id":N, _thumbnail_id, ACF gallery rows must continue to resolve |
Surviving content references attachments by ID (wp-image-N class, "id":N block attr, _thumbnail_id, ACF gallery numeric IDs). Rewriting all references is intractable; the migration must put each attachment back at its original ID. Mechanism: pass ID explicitly into $wpdb->insert( $wpdb->posts, [...] ) instead of letting MySQL autoincrement. Since the slots were freed, MySQL's autoincrement counter (max-ever-issued) keeps future uploads above the restored range.
The single biggest mistake of this playbook (real production incident): bulk-deleting attachments flagged "do not migrate" without checking whether they're still referenced by surviving content. wp_delete_post( $id, true ) for attachments calls wp_delete_attachment() internally, which also deletes the file from disk. Irrecoverable without backup.
Guardrail: before deleting any attachment row, intersect its ID with the live reference set computed by scanning surviving post_content and wp_postmeta (postmeta numeric values, wp-image-N, "id":N, attachment_id=N, filename match, custom image-meta keys). Skip + log instead of delete when the intersection is non-empty.
The full seven-source reference scanner, the ID-preservation mechanism with collision guard, the two-tier recovery, the sideload pipeline, image-format gotchas (SVG / WebP / animated GIF / oversized originals / filename collisions / EXIF), and orphan detection all live in references/media-deep-dive.md. Step-by-step recovery when files are already gone lives in references/media-recovery-checklist.md.
Every dropped record must have a 301 destination resolved before the transform runs. Deletion without redirect loses link equity, breaks hreflang, breaks external references, breaks indexed URLs in search results. The redirect map is a delivery artifact, not a cleanup pass.
Plugin-based redirect tables (Yoast Redirects, Rank Math, Redirection) become an operational footgun at scale:
Ship via host:
source destination, status applied globally at import.If existing redirects live in plugin storage (Yoast, Rank Math), extract them as part of the migration — hunt them with the queries in references/pre-flight-sql.md. See §8 and the Yoast note below.
For inventory-driven migrations, the redirect map is built from multiple sources, priority-merged with later sources losing on source-URL conflicts. Full priority table + conflict resolution in references/redirect-source-priority.md:
| Priority | Source |
|---|---|
| 1 | Inventory Migrate row with New URL ≠ Existing URL |
| 2 | Inventory Build row pointing at yet-to-be-built URL |
| 3 | Inventory Do not migrate row with explicit Redirect column |
| 4 | Legacy redirect plugin's regex rules |
| 5 | Legacy redirect plugin's plain rules |
| 6 | DNM row with empty Redirect → fallback rule table (closest topical archive → parent → homepage) |
Yoast stores redirects in three option keys, not one:
| Option key | Role |
|---|---|
wpseo-premium-redirects-base | Authoritative. Read+written by the Yoast UI. Per-entry shape: {origin, url, type, format} where format is plain or regex. |
wpseo-premium-redirects-export-plain | Denormalized snapshot for the export feature. Can lag the base (incomplete admin drafts filtered). |
wpseo-premium-redirects-export-regex | Same role, regex rules. Same lag caveat. |
Implication: read -base to get every redirect; the export options are incomplete. Delete -base to make the UI show zero. Clean teardown deletes all three. General lesson: anywhere a plugin offers an "export" feature, suspect a primary store underneath and treat export keys as denormalized projections — read the primary.
wp db import is broken — pipe via STDINwp db import file.sql fails with ERROR 1064 near 'SOURCE …' because it issues SOURCE via mysql -e (non-interactive). SOURCE is a mysql shell builtin, valid only in interactive mode. Newer mysql clients reject it.
Workaround:
wp db cli < /absolute/path/to/file.sql
wp db cli opens an interactive-equivalent mysql session and reads SQL from STDIN. Works for the entire dump including extended INSERT statements.
$wpdb mutationsBulk migrations bypass WP's high-level APIs and write directly via $wpdb for performance — update_option(), update_post_meta(), wp_update_post() run filters / fire actions / create revisions / let plugins rewrite values, which is too slow at migration scale.
On hosts with a persistent object cache (WP Engine's memcached drop-in, Pantheon's Redis), the cache holds stale values after direct $wpdb writes. A subsequent get_option() / get_post_meta() returns the pre-mutation value.
Discipline: wp cache flush between bulk-transform steps. Alternatively, surgical wp_cache_delete( $key, $group ) for the specific keys mutated. On stock WP without a drop-in the object cache is per-request and dies between CLI invocations — the bug rarely surfaces. Production hosts always run a persistent cache → assume it's persistent.
terminus wp <site>.<env> -- db query cannot pipe local files. SSH into the app container first:
terminus ssh <site>.<env>
# inside the container:
wp db query < /code/plugins/<plugin>/scripts/migration/01-import-tables.sql
The "This environment is in read-only Git mode" warning is harmless for DB operations.
wp_parse_url URL-path-vs-query trapwp_parse_url('https://example.com/?p=1', PHP_URL_PATH) returns /, not ''. Naive homepage matchers (e.g. "if path is /, treat as homepage and return page_on_front") swallow /?p=N URLs as homepage matches.
Real production bug: every /?p=N query-string URL flagged "do not migrate" was matched to the homepage's wp_posts.ID, then the DNM-delete step deleted the homepage along with the actual DNM URLs.
Guard: when treating a URL as the homepage, also check the QUERY component is empty:
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
$query = (string) wp_parse_url( $url, PHP_URL_QUERY );
if ( $query !== '' ) {
return 0; // not the homepage — defer to query-string matcher
}
// homepage match logic
post permalink vs permalink_structureWP's default post post type has no rewrite['slug'] registered. Verifiers that derive the post URL from rewrite.slug will emit /post/<slug>/ regardless of what permalink_structure actually says. If the site uses /blog/%postname%/, the verifier will false-positive every blog post as a mismatch.
Fix: if get_option('permalink_structure') is non-empty, defer to get_permalink( $post_id ). Fall back to derive-from-parts only for plain-permalink test environments.
Short, single-segment vendor namespaces in a migration plugin collide with framework and autoloader conventions. Pick a two-segment namespace (Vendor\\MigrationPlugin) for the plugin's own classes to avoid clashing with WP, host MU-plugins, or shared Composer dependencies.
After Tier 3 runs, generate an expected vs actual report per inventory row. Inventory says "this row should land at URL X with post_type Y and SEO title Z" — verifier reads the actual wp_posts + wp_postmeta + permalink and compares.
| Status | Meaning |
|---|---|
PASS | All expected fields match. |
PASS_BUILD | Net-new row; verify by hand once authored. |
MISMATCH | Migrate row drifted from expected. Investigate. |
DELETED | Migrate row's post is missing. Should not happen — investigate. |
NO_MATCH | Inventory never resolved a legacy_post_id. |
NOT_DELETED | DNM row's post still exists. |
NO_REDIRECT | DNM row has no redirect rule. |
Filter MISMATCH / NOT_DELETED / NO_REDIRECT / DELETED subsets for client sign-off.
The per-row report catches drift on matched rows; it does not catch silent bulk loss. Reconcile aggregate counts against the pre-flight baseline (captured before any transform — see references/pre-flight-sql.md):
SELECT post_type, post_status, COUNT(*) FROM wp_posts GROUP BY post_type, post_status;
SELECT taxonomy, COUNT(*) FROM wp_term_taxonomy GROUP BY taxonomy;
SELECT COUNT(*) FROM wp_users;
Expected target counts = source counts − intentional drops (DNM deletes, orphan sweeps, discarded cohorts) + intentional adds. Any unexplained delta is a bug — investigate before sign-off.
The dump is your rollback artifact. Drill:
wp db reset --yes
wp db cli < legacy-dump.sql
# rerun Tier 1–3 with the fix in place
If the bug only damaged a specific cohort (e.g. attachments) and the rest completed correctly, prefer targeted recovery (REST-based restore + manifest-driven registrar, see references/media-recovery-checklist.md) over a full re-import. Full re-import loses any editorial work done since the migration ran.
Verifier output is a heuristic comparison, not authoritative truth. Real bugs found in real verifiers:
permalink_structure and using rewrite.slug directly → many false MISMATCH rows.Cross-check verifier MISMATCH counts against actual site behavior (get_permalink(), browser smoke tests) before chasing them as data issues.
wp_insert_post() for tens of thousands of records. Triggers hooks, revisions, sanitization. Use direct DB INSERT (ID-preserving) + post-transform wp_cache_flush()._thumbnail_id cleanup to detect orphan attachments. Misses <img wp-image-N>, "id":N block attrs, ACF galleries, _wp_attachment_metadata inside other postmeta. Multi-source reference scan is mandatory.get_permalink() and browser smoke tests before chasing MISMATCHes as data issues.references/runbook.md — ordered phase sequence for executing a migration, plus large-import tuning.references/search-replace.md — serialized-safe URL / domain rewrite (wp search-replace), multisite and block-URL gotchas.references/pre-flight-sql.md — runnable discovery SQL for sizing, scope, and recovery planning.references/media-deep-dive.md — reference scanner, ID preservation, recovery, sideload, image-format gotchas.references/cli-flag-conventions.md — CLI subcommand contract used across every transform/cleanup command.references/media-recovery-checklist.md — step-by-step recovery when attachments have been wrongly deleted.references/redirect-source-priority.md — full priority table for redirect-map source merging.Provides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
npx claudepluginhub s3rgiosan/wp-skills --plugin wp-migration-playbook