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:
- product/pricing configuration and any Stripe Connect account IDs already issued
- saved/collection records, votes, view counts
- messages and conversations
- reviews and their sub-scores (
communication,accuracy,valueare visible on profiles) - timeline/activity and check-ins
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.
- Import production's
website/snippet/asset/article/job/pagecontent in full - Import staging's
addon/deal/workflow/meetup/profilecontent — these exist nowhere else - For rows present in both, prefer production, but merge in any staging-only fields
- 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_usermeta → users + profiles.
user_login→users.username;user_email→users.email;display_name→profiles.display_nameuser_registered→users.created_at(drives "Member since")user_pass→users.legacy_password. Keep the phpass hash. Laravel verifies it on first login and transparently rehashes to bcrypt:
// 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.
- Avatars: WP media or Gravatar → download, re-upload to R2,
profiles.avatar_id - Skills: parse the Voxel taxonomy →
skills+profile_skills - Ratings:
rating_avgand the three sub-scores are recomputed from imported reviews, not copied. Copying aggregates that don't match their underlying rows creates bugs that surface months later.
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 |
body — convert 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:
- website →
live_url, verified flag/timestamp,website_type/plugins_used/page_builderterms - snippet → split the Voxel repeater into
snippet_blocksrows withlanguage+position; re-highlight on import, do not migrate cached highlight HTML - asset/addon →
productsrow: price, is_free,external_url, license config. Files move to R2 and becomeproduct_versionsv1.0.0 with changelog"Migrated from voxel.guide" - job → enum mapping: project type, experience level, payment type, budget
- meetup → dates with timezone. WP stores site-local; convert to UTC explicitly and store the original timezone. Getting this wrong silently shifts every event
- deal / workflow → straightforward
Listingfields
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:
- 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. - Parse
_elementor_datafor the widget types actually in use (text-editor, heading, image, video, code, spacer). A ~200-line mapper covers the long tail. - 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:
- Never migrate raw card or bank data. Only Stripe object IDs.
- Existing Stripe Connect account IDs carry over directly onto
vendors.stripe_account_id— vendors do not re-onboard. Verify each withGET /v1/accounts/{id}during import and recordcharges_enabled/payouts_enabledfrom the live response, not from WP. - Historical orders import read-only, for buyer download history and vendor reporting. They are not refundable through the new UI; refunds on legacy orders go through the Stripe dashboard.
- Every past purchase must produce a working entitlement — a license key and a download that resolves. This is the thing customers will test on day one. Generate keys for legacy purchases that never had them, and email them.
- Reconcile: total imported
order_itemscount and sum must match the WP figures exactly. Fail the import on any discrepancy rather than logging a warning.
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
- DB access. Required. Everything in §1 depends on it, and password preservation (§4.1) is
impossible without
wp_users. - Prod-as-truth confirmation (§3).
- Legacy order visibility — import full history, or last 24 months only?
- 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.
- Merch — empty today. Drop it and 301 to
/, or keep as print-on-demand link-out? - 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:
legacy_map+ the users importer, run against a prod clone- The websites importer end-to-end, including media and Elementor tier 1
- 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.