Moving recurring revenue from WooCommerce Subscriptions to Shopware 6 is not a row dump. Woo encodes state across wp_posts (subscription post type), child renewal orders, order item meta, and payment token tables. Shopware (with a subscription extension) expects clean entities, stable billing anchors, and idempotent imports so finance can sign off before DNS moves.
This note is how I structured the migration for a pet-health / D2C brand: zero duplicate billings, dry-run reports for stakeholders, and a cutover window measured in hours—not weekends lost to mystery SQL.
1. Source-of-truth inventory (before any code)
Woo side — what we catalogued
| Concept | Typical Woo storage | Notes |
|---|---|---|
| Subscription identity | shop_subscription post + ID |
Stable external key for Shopware |
| Status | Post status + scheduled actions | Map to Shopware/extension states explicitly |
| Line items | Renewal order line items | SKU, qty, interval, trial flags |
| Customer | customer_id or guest email |
Email is not unique enough—use Woo customer ID + email |
| Next payment | _schedule_next_payment style meta |
Normalize to UTC |
| Payment method | Token / gateway meta | Legal + PSP review before assuming portability |
Shopware side — what we had to decide
- Which subscription extension is canonical (billing engine, dunning, admin UI).
- Whether historical renewals become Shopware orders or stay in a read-only archive (we kept PDF + CSV archive in object storage for support).
- How tax is calculated post-move (Avalara in Shopware vs Woo tax tables)—must match for first renewal.
2. Pipeline architecture
Stage A — Extract (read-only Woo)
- Custom Artisan (or Symfony console) command connecting to a read replica when available.
- Export normalized JSON lines per subscription: one file per batch or streaming NDJSON for large catalogs.
- Never mutate Woo during extract.
Stage B — Transform
- SKU map: CSV maintained by merchandising (
woo_sku→shopware_product_number); reject unknown SKUs in CI. - Customer map:
upsertShopware customers by email + Woo ID stored incustomFields.woo_customer_id. - Interval map: Woo “every 2 months” → extension-specific interval codes—documented in a single YAML file versioned in git.
Stage C — Load (Shopware Admin API / DAL)
- Per-customer transactions where the DB allowed: if one line fails, we don’t half-import a household’s subscriptions.
firstOrCreate-style behaviour onwoo_subscription_idin custom fields so re-runs are safe.- Rate limiting: sleep / throttle Admin API to avoid 429s on shared hosting.
Stage D — Verify (parallel run)
- Nightly job for two weeks pre-cutover: compare Woo “next run date” vs Shopware next billing date ± tolerance.
- Slack alert on any drift above threshold.
3. Idempotency contract
Every migrated subscription carries:
customFields.migration_source = "woocommerce"
customFields.woo_subscription_id = "<integer>"
Shopware uniqueness: enforce unique index on woo_subscription_id inside the extension’s table or via application check before insert.
Dry run mode: --dry-run prints would-create / would-skip counts and writes a CSV for finance—no HTTP writes.
4. Payment tokens and the hard conversation
Most gateways do not let you “copy” payment_method_meta into another platform’s vault. The viable patterns:
- PSP-hosted migration — Stripe/Brale/Adyen migration APIs move the mandate with customer consent.
- Grandfather on Woo until natural churn — only for small tail; legal/marketing must agree.
- Re-collect — email flow to add card in Shopware; highest drop-off, lowest risk.
We documented the PSP path in the runbook so support did not improvise under pressure.
5. Cutover playbook (condensed)
- T-7: freeze Woo subscription edits (address changes go through support with dual-write checklist).
- T-1: final delta export; load to staging Shopware; sign-off from finance + CX.
- T-0: maintenance banner; read-only Woo; final incremental import; smoke tests on next renewal simulation.
- T+0: DNS / primary checkout to Shopware; Woo admin stays read-only 14 days for disputes.
- T+14: archive Woo DB snapshot to cold storage.
6. What I’d do differently next time
- Start PSP migration contracts earlier—they drive timeline more than PHP.
- Generate per-subscription PDF receipts pre-migration for CX deflection.
Resume framing
Own the inventory → transform → load → verify pipeline, the idempotency contract, and the cutover runbook—that is staff-level delivery, not “I ran a one-off script.”