Voxel Guide — Migration Plan

From: WordPress 6.x + Voxel theme + Elementor, two diverging installs To: Laravel 13 monolith, PostgreSQL 16 Governing constraint: ~500 indexed URLs carry the site's traffic. Zero may 404.

0. What this plan is based on — and its one gap

Everything below was derived from a live audit: both sitemaps, both plugin manifests via the xCloud API, the WordPress REST API, and page-level scraping of every archive and a sample of detail pages.

The gap: direct database access was not available during this audit. The server (voxel, shared by both installs) accepts SSH as donald, but sudo requires a password and the WordPress site users are not readable by that account. Voxel's custom post types are not exposed over the REST API, so field-level truth — exact meta keys, taxonomy term IDs, user counts, order history — has not been read directly.

Consequence: the field mappings in §4 are inferred from rendered output and are ~90% reliable, not 100%. Before writing a single migration script, run §1. If the schema differs from what is assumed here, the shape of this plan holds but individual column mappings will need correcting.

Do not skip §1. Discovering a mismatched meta key during a production cutover is the single most expensive way to find this out.


1. Phase 0 — Establish ground truth (do this first)

Get shell with sufficient privilege on the server, then capture the real schema:

# on the server, as a user that can read the site roots
WP="wp --path=/home/u5_voxelguide_d/htdocs --skip-plugins=elementor"

$WP post-type list --fields=name,label,public,count          > types.tsv
$WP taxonomy  list --fields=name,label,object_type,count     > taxonomies.tsv
$WP user      list --fields=ID,user_login,user_email,roles   > users.tsv
$WP db query "SELECT meta_key, COUNT(*) c FROM wp_postmeta
              GROUP BY meta_key ORDER BY c DESC" --skip-column-names > meta_keys.tsv
$WP db query "SELECT post_type, post_status, COUNT(*)
              FROM wp_posts GROUP BY 1,2" --skip-column-names
$WP db query "SHOW TABLES LIKE 'wp_voxel%'"
$WP db query "SHOW TABLES LIKE '%order%'"

$WP db export --add-drop-table prod-$(date +%F).sql

Run the identical capture against stagingvoxel (/home/stagingvoxel/htdocs). Diff the two.

Deliverable of this phase: a field-map.csv with one row per (post_type, meta_key) → target column, reviewed by a human. Every subsequent script reads from that file. This is the single source of truth for the whole migration and it is worth two days of care.

Also capture, from Voxel's own tables, the things that are not posts:


2. Sequencing — strangler, not big-bang

A single-weekend cutover on a site with 500 indexed URLs, a live marketplace, and vendor payouts is an unnecessary risk. Run the new app alongside the old one and move traffic per section.

Stage 1  Laravel live at new.voxel.guide, read-only mirror. Nightly sync from WP.
         Nothing user-facing changes. Purpose: prove the importer against real data, repeatedly.

Stage 2  Move the read-heavy, low-risk sections first: /websites, /snippets, /learn/*.
         Cloudflare routes those paths to Laravel; everything else still WordPress.
         Writes (submissions) still happen in WP and sync forward.

Stage 3  Move identity. Laravel becomes the IdP; WordPress authenticates against it.
         This is the risky step and it gets its own window and its own rollback.

Stage 4  Move profiles, /hire, messaging, saves, votes. WP stops owning user-generated interaction.

Stage 5  Move the marketplace. Freeze new orders in WP, migrate catalogue + entitlements + licenses,
         re-point Stripe webhooks, unfreeze in Laravel. Shortest possible freeze (target < 2h).

Stage 6  Move jobs/deals/workflows/meetups. Retire WordPress front end.
         FluentCommunity/Support/CRM remain, now behind SSO.

Cloudflare sits in front and does path-based routing, so each stage is a config change with an instant rollback. That property is the entire reason for this shape.


3. Reconciling two diverging datasets

This is the part most likely to go wrong, and it is specific to this project.

Production (voxel.guide) and staging (stagingvoxel.wp1.site) are not ancestor and descendant. They forked. Staging was built as the "newer version", gained five new content types (addon, deal, workflow, meetup, profile) and new plugins (multi-vendor quotes, phone verification), while production kept receiving real submissions and now has more content in the shared types:

Type Prod Staging
website 120 ~103
snippet 114 fewer
asset 102 fewer

Recommended resolution — production is the source of truth for content; staging is the source of truth for schema.

  1. Import production's website/snippet/asset/article/job/page content in full
  2. Import staging's addon/deal/workflow/meetup/profile content — these exist nowhere else
  3. For rows present in both, prefer production, but merge in any staging-only fields
  4. Match on post_name (slug) first, then on normalised title, then flag for human review

Expect a manual review list of roughly 30–80 items. Budget a day for someone who knows the content to work through it. Produce it as a CSV with both versions side by side and a "keep prod / keep staging / merge" column — that is far faster to review than a UI.

Never resolve conflicts automatically by post_modified. Staging rows were touched by theme/plugin operations that changed timestamps without changing meaning.


4. Field mapping

Written as an idempotent, re-runnable importer — not a one-shot script. It must be safe to run fifty times during Stage 1.

// Every importer keys on legacy ID so re-runs update rather than duplicate.
// legacy_map: (source_site, legacy_type, legacy_id) -> (model_type, model_id)

4.1 Users

wp_users + wp_usermetausers + profiles.

// In a custom Auth guard / login action
if ($user->password === null && $user->legacy_password) {
    if ((new PasswordHash(8, true))->CheckPassword($plain, $user->legacy_password)) {
        $user->forceFill([
            'password' => Hash::make($plain),
            'legacy_password' => null,
        ])->save();
        return true;   // logged in, migrated silently
    }
    return false;
}

This preserves every account without a mass password-reset email. If the hashes cannot be exported, the fallback is a reset campaign — assume meaningful attrition and plan comms accordingly.

4.2 Posts → listings

For each type, wp_posts + wp_postmeta → the target table in SPEC.md §3.2.

WP source Target
ID legacy_map.legacy_id
post_name slug
post_title title
post_content bodyconvert Elementor output, see §4.3
post_excerpt excerpt
post_status (publish/draft/pending) status
post_date_gmt published_at
post_author user_id (via legacy_map)
_thumbnail_id primary media row
Voxel gallery meta ordered media rows
Voxel view counter views
SEOPress _seopress_titles_title / _desc meta_title / meta_description

Type-specific:

4.3 The Elementor problem

Body content is not clean HTML. Elementor stores layout as JSON in _elementor_data and post_content often holds shortcodes or a stub. Three tiers, applied in order:

  1. Prefer rendered output. For each post, fetch the live rendered page and extract the main content region, then sanitise to an allow-list (p, h2–h4, ul, ol, li, a, strong, em, code, pre, img, blockquote, table). This captures what readers actually see. Cache to disk so re-runs don't re-crawl.
  2. Parse _elementor_data for the widget types actually in use (text-editor, heading, image, video, code, spacer). A ~200-line mapper covers the long tail.
  3. Flag the remainder for manual rewrite. Expect 20–40 pages, mostly the marketing/landing pages — which are being redesigned anyway, so this is not wasted effort.

Run tier 1 against everything first and eyeball a sample of 30 before trusting it.

4.4 Media

~1,500–3,000 attachments expected (showcase screenshots dominate).

wp-content/uploads/**  →  R2 bucket, path-preserved under /legacy/

Preserve original paths so any hardcoded <img src> in migrated body HTML still resolves via a Cloudflare rule, then rewrite references in a second pass. Regenerate derivatives on the new pipeline; do not migrate WordPress's -768x444-style thumbnails — they encode the old theme's breakpoints.

Strip EXIF. Re-run ShortPixel-equivalent optimisation on ingest.

4.5 Commerce and entitlements

The highest-stakes data. Rules:

4.6 Interactions

Saves, votes, view counts, comments, messages, check-ins, and reports all migrate by legacy ID. View counts are vanity but visible on every card — carry them, or the site looks like it launched yesterday.


5. URL preservation

The deliverable is a redirects table with every legacy URL. Build it mechanically:

# harvest every URL from both installs
for t in post page website asset article job snippet; do
  curl -s "https://voxel.guide/$t-sitemap1.xml"
done | grep -oP '(?<=<loc>)[^<]+' | sort -u > legacy-urls.txt

Add: taxonomy archives, paginated archives, author pages, feeds, attachment pages, and anything with inbound links (pull from Search Console + Ahrefs/Bing exports — sitemaps alone miss orphaned but ranking URLs).

Mapping rules:

Legacy New Code
/website/{slug} /websites/{slug} 301
/asset/{slug} /assets/{slug} 301
/snippet/{slug} /snippets/{slug} 301
/addon/{slug} /addons/{slug} 301
/profile/{user} /@{user} 301
/voxel-addons/ /addons 301
/tips-n-tricks/ /learn/tips 301
/voxel-how-to-videos/ /learn/videos 301
/faqs/ /learn/faqs 301
/walkthrough/ /learn/walkthrough 301
/login-registration/ /login 301
/create-{type}/ /submit/{type} 301
/portal/ /dashboard 302
/merchandise/ decide (§7)
/?p={id} resolved via legacy_map 301

Gate on this: a CI job walks legacy-urls.txt against staging and fails the deploy if any URL returns anything other than 200 or a 301 terminating in a 200. Run it on every deploy, not just once.

Keep /sitemaps.xml responding at its current path — it is referenced in robots.txt and Search Console — while also serving the new index.


6. Cutover runbook (Stage 5, the marketplace window)

Target: under two hours. Rehearse it twice against a production clone.

T-7d   Full dry run on a clone. Record every timing. Fix what broke.
T-2d   Freeze schema changes. Final redirect map reviewed and merged.
T-1d   Pre-sync everything except orders (bulk of the data moves now, not during the window).
T-0h   Maintenance banner on WP store pages. Disable new checkouts.
T+0m   Final delta sync: orders, licenses, entitlements since pre-sync.
T+20m  Reconcile: order counts, order totals, license counts, entitlement counts.
       Any mismatch → STOP, roll back, no exceptions.
T+35m  Re-point Stripe webhooks to Laravel. Verify with a test event.
T+45m  Flip Cloudflare routes for /assets, /addons, /cart, /checkout, /dashboard.
T+50m  Smoke: buy a $1 test product end-to-end with a real card. Verify fee split,
       vendor transfer, license issuance, download, and the vendor-side dashboard row.
T+60m  Re-enable checkout. Lift banner.
T+24h  Watch: 404 rate, Stripe webhook failures, download errors, support inbox.
T+7d   Search Console coverage check; compare indexed count to pre-launch baseline.

Rollback: revert the Cloudflare route, re-enable WP checkout, re-point Stripe webhooks back. Because Laravel has been read-only-mirroring until this window, rolling back loses at most the orders placed in the window itself — which is why the window is kept short and checkout is frozen during it.


7. Decisions needed before Phase 0 completes

  1. DB access. Required. Everything in §1 depends on it, and password preservation (§4.1) is impossible without wp_users.
  2. Prod-as-truth confirmation (§3).
  3. Legacy order visibility — import full history, or last 24 months only?
  4. Link-out addons — do vendors selling on their own sites stay link-out, or is on-platform selling mandatory at migration? This materially changes Phase 3 scope.
  5. Merch — empty today. Drop it and 301 to /, or keep as print-on-demand link-out?
  6. accessiBe — recommend cancelling at cutover (see SPEC.md §7.2). Confirm.

8. Risk register

Risk Likelihood Impact Mitigation
Meta keys differ from inferred map High Medium §1 before any code. Field map reviewed by hand
Elementor content doesn't convert cleanly High Medium Three-tier strategy §4.3; manual list is small and overlaps the redesign
Traffic loss from URL changes Medium Severe Complete redirect map + CI gate on every deploy + Search Console monitoring
Password hashes unavailable Medium High Confirm early. Fallback: staged reset campaign with comms
Prod/staging merge conflicts High Medium Human-reviewed CSV, prod wins by default
Stripe Connect accounts don't carry Low Severe Verify each against the live API during import; a re-onboarding campaign is a last resort
Legacy entitlements break Medium Severe Explicit reconciliation gate at T+20m; generate missing keys
Meetup timezones shift silently Medium Low Convert explicitly to UTC, store source timezone, spot-check past events
SSO bridge slips Medium High It is two weeks of real work. Sequence it as its own stage (Stage 3) with its own rollback
Scope creep into community/support High Medium Boundary is set in SPEC.md §9. Hold it

9. What to build first

If you want the earliest possible signal that this plan is sound, build in this order:

  1. legacy_map + the users importer, run against a prod clone
  2. The websites importer end-to-end, including media and Elementor tier 1
  3. Render 20 imported showcases in the new app and compare against live, side by side

That is roughly a week and it de-risks the two things most likely to derail the project: field mapping and content conversion. Everything else is ordinary application work.