Notes / April 3, 2026

Building a WooCommerce → Shopware subscription migration tool

Moving recurring revenue from Woo Subscriptions into Shopware: mapping, idempotency, and safe cutover.

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_skushopware_product_number); reject unknown SKUs in CI.
  • Customer map: upsert Shopware customers by email + Woo ID stored in customFields.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 on woo_subscription_id in 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:

  1. PSP-hosted migration — Stripe/Brale/Adyen migration APIs move the mandate with customer consent.
  2. Grandfather on Woo until natural churn — only for small tail; legal/marketing must agree.
  3. 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)

  1. T-7: freeze Woo subscription edits (address changes go through support with dual-write checklist).
  2. T-1: final delta export; load to staging Shopware; sign-off from finance + CX.
  3. T-0: maintenance banner; read-only Woo; final incremental import; smoke tests on next renewal simulation.
  4. T+0: DNS / primary checkout to Shopware; Woo admin stays read-only 14 days for disputes.
  5. 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.”