← Blog

Migration

Magento 1 to Medusa 2: a six-week shadow cutover playbook

A Maastricht orthopedic supplier had 9,400 custom prosthesis configurations stuck in Magento 1.9. We moved them onto Medusa 2 in six weeks without dropping a single order.

Jacob Molkenboer· Founder · A Brand New Company· 17 Jun 2026· 10 min
Open leather ledger with brass tag, green ribbon marker, brass key, index card on ivory paper in dim light.

The HEAD response on the Maastricht supplier's storefront still served a Magento 1.9 footer. PHP 5.6 in the response headers. End of life since June 2020 for the platform, December 2018 for the runtime. Twenty-two people inside the company, 9,400 custom prosthesis configurations behind the B2B login, and a portal that 180 hospitals and orthotic clinics across the Benelux relied on every Tuesday to file the week's inkooporders.

The site had been patched, monkey-patched, and back-ported for six years. The last security audit recommended one thing: stop patching, start moving.

This is the playbook we used to move them off in six weeks without dropping an order.

The audit before the audit

Before any architectural decision, we spent four days reading the codebase. Not running it. Reading it.

Magento 1.9 sites that have lived for a decade are never just Magento. The orthopedie supplier's portal had:

  • 41 custom modules under app/code/local, only seven of which had a working test.
  • A ProthesisConfigurator module that wrote configurations into seven custom MySQL tables outside Magento's EAV.
  • A nightly cron at 02:30 pulling inkooporders from AFAS Profit over SOAP.
  • A per-customer prijsafspraak engine that overrode Magento's tier pricing with a price index loaded from a CSV the sales team uploaded.
  • 14 hardcoded SKUs in the checkout that the original developer (who left in 2017) had wired directly into Mage_Sales.

You cannot migrate what you do not understand. We mapped every custom write path before we touched a single config file.

Why Medusa 2, not Shopify B2B

We considered three targets.

Shopify B2B Plus was fast to evaluate and fast to dismiss. 9,400 SKU variants is over Shopify's hard variant ceiling even with metaobjects, and the per-customer pricing logic would have run inside Shopify Functions on every cart calculation. The AFAS bridge would have lived on a Cloudflare Worker, adding a network hop the supplier's IT lead vetoed in the first meeting.

BigCommerce B2B Edition was the runner-up, but their headless story still routes through the BigCommerce checkout. The compliance team had ruled that out: AFAS Profit needs to issue an invoice number before the checkout completes, and the BigCommerce checkout does not let you block on an external call.

We picked Medusa 2: a Node.js commerce kernel that runs in your own stack. You own the database. You write modules in TypeScript that look like the modules the previous developer wrote in PHP, just sane. It speaks Postgres, ships with a workflow engine, and the storefront is just Next.js. Done.

Week one, the data spine

The biggest mistake teams make in this kind of migration is treating data as a one-shot import. It is not. For six weeks both stacks need to read and write the same source of truth, or someone's order falls into a crack.

We started by introducing Postgres alongside the existing MySQL. Same VPC, same security group, clean slate. Then we built a one-way replicator that picked up CRUD events from MySQL's binlog through Debezium, transformed each row into the Medusa shape, and wrote it into Postgres.

Inkooporders, configuraties, klanten, prijsafspraken, all of it.

By end of week one the Postgres copy was behind by about 45 seconds, which is fine for everything except checkout writes. Checkout was week two's problem.

Week two, dual-writing the checkout

Now the dangerous part. The checkout had to start writing into Postgres too, so that orders placed on the legacy portal also existed in Medusa.

We did this by intercepting Magento's sales_order_save_after event with a small custom module and posting a normalised payload to a Node.js bridge running on the same box:

<?php
class OrtoSync_Bridge_Model_Observer
{
    public function salesOrderSaveAfter(Varien_Event_Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        if ($order->getOrigData() !== null) {
            return; // only on first save, never on updates
        }
        try {
            $payload = Mage::helper('ortosync')->normalizeOrder($order);
            Mage::helper('ortosync/bridge')->post('/orders', $payload);
        } catch (Exception $e) {
            Mage::log($e->getMessage(), null, 'ortosync.log');
            // never throw. the legacy order must save no matter what.
        }
    }
}

The bridge accepted the payload, ran it through Medusa's order workflow, and tagged the result with a magento_order_id. If Medusa rejected it for any reason (validation, missing customer, anything), the bridge logged it and the Magento order still saved normally. No regression risk to the live business.

By end of week two, every new order existed in both databases. We watched the divergence count fall from 19 per day to zero across four days as we fixed mismatches one at a time.

Warning

Never let your dual-write fail loudly on the legacy side. The new stack must absorb its own errors. If your old checkout starts returning 500s because the new system is unhappy, you have created an outage instead of preventing one.

Week three, the AFAS Profit bridge

The nightly inkooporder-sync was the highest-risk piece. AFAS Profit's SOAP connector is fine when it works, but the legacy cron job retried failures forever, which had once flooded the supplier's AFAS instance with 14,000 duplicate orders during a network blip in 2022.

We rewrote it as a Medusa workflow with three steps:

  1. Pull the day's purchase orders from AFAS via the Get connector over REST, not SOAP. AFAS shipped REST endpoints in 2023 and they are stricter and faster.
  2. Reconcile each one against Medusa's order table by purchase_order_number.
  3. Mark mismatches for human review in the admin panel instead of retrying.

The workflow ran in parallel with the legacy cron for two weeks. We compared outputs every morning. Forty-three discrepancies surfaced. Forty-one were the legacy cron getting it wrong (silently swallowing AFAS validation errors). Two were our new code. We fixed those.

Week four, the configurator

The ProthesisConfigurator was the heart of the system. A customer picks a base prosthesis, then makes between four and twenty-three choices (material, joint type, sleeve, alignment, cosmetic finish), and the result is a SKU that sometimes already exists in the catalog and sometimes needs to be generated on the fly.

The legacy implementation lived in a 2,400-line PHP class that read from those seven custom tables. We rebuilt it as a Medusa module:

import { MedusaService } from "@medusajs/framework/utils"
import { Configuration, Variant } from "./models"

class ConfiguratorService extends MedusaService({
  Configuration,
  Variant,
}) {
  async resolveConfiguration(input: ConfigInput): Promise<string> {
    const fingerprint = hashChoices(input.choices)
    const existing = await this.retrieveVariantByFingerprint(fingerprint)
    if (existing) return existing.id

    const created = await this.createVariant({
      product_id: input.base_product_id,
      sku: buildSku(input.base_sku, fingerprint),
      metadata: { fingerprint, choices: input.choices },
    })
    return created.id
  }
}

The module exposes a single resolveConfiguration call that the Next.js storefront hits over Medusa's API. It returns either an existing variant id or generates a new one and registers it as a real Medusa product variant before returning.

The 9,400 historical configurations were imported as Medusa variants in a one-time script. Each one kept its original SKU so customer reorder flows kept working without a SKU translation layer.

Week five, per-client price agreements

The CSV-upload pricing engine was the part we were most tempted to rewrite. We did not. Lesson learned across earlier migrations: when sales has a workflow that works, you do not replace it during a tech migration. You replace the tech and you leave the workflow alone.

We built a Medusa Price List module that accepted the exact CSV format the sales team had uploaded for six years. Same columns, same encoding, same questionable handling of commas inside quoted fields. The only difference was that the upload now landed in S3 and triggered a workflow that diffed against the current price list before applying.

The sales team did not have to change a thing. They noticed because the upload page was faster.

Week six, the cutover

By Monday of week six, Medusa had been receiving every write for three weeks. Postgres and MySQL were within seconds of each other on every table that mattered. The Next.js storefront had been live on a staging subdomain for ten days. Twelve internal users had placed test orders. The sales team had uploaded three real price lists into the new system.

We cut over on a Thursday at 14:00 CET. Not Friday. Never Friday.

The DNS flip pointed orders.example.nl at the Next.js storefront. The Magento storefront kept running on legacy.orders.example.nl for one more week as a fallback. We watched logs for 90 minutes. Two customers hit a bug where the cart did not show their prijsafspraak (a cache key collision we had missed in staging). We patched it in eleven minutes. No orders lost.

By Friday morning the legacy bridge was disabled. By the following Wednesday the old VPS was archived. The dual-write infrastructure ran for three more weeks as cheap insurance.

What we would do differently

Two things.

First, we underestimated how long the configurator's edge cases would take to flush out. The 9,400 configurations included 47 that had been manually edited in the database in 2018 to fix a billing dispute and never re-saved through the UI. They imported as broken variants. We caught them, but only because a sales rep noticed a SKU rendering with a missing image during the test phase. Build a configuration integrity check before you import, not after.

Second, the AFAS REST migration could have happened independently of the storefront migration. We bundled them because we wanted one cutover, but in retrospect the REST switch could have shipped six months earlier with zero storefront impact and would have de-risked the bigger move.

When we ran this legacy migration for the orthopedie supplier, the thing that nearly bit us was the cache key collision on prijsafspraken (legacy Magento hashed customer plus SKU, our Next.js layer was keying on customer plus variant_id, and the two diverged for configurator outputs). We caught it in production and now every dual-write bridge we build logs the cache key alongside the order payload.

The smallest useful thing you can do after reading this: open your Magento 1 site's HEAD response and check the X-Powered-By header. If it says PHP 5.6 or PHP 7.0, you are running a runtime that has been out of security support for years. The migration is the project. Knowing exactly which runtime is serving your customers is a fifteen-second audit you can do today.

Key takeaway

Shadow-traffic cutovers let you catch the silent AFAS retries and cache-key collisions before customers feel them, not after the DNS flip.

FAQ

Why pick Medusa 2 instead of Shopify B2B for a portal like this?

Shopify's variant ceiling and checkout lock-in were both blockers. The configurator generates SKUs on demand, and AFAS needs to issue invoice numbers before checkout completes. Medusa lets us own the kernel and the checkout flow.

How long should a Magento 1 migration realistically take?

For a custom B2B portal with this integration surface, six engineering weeks plus two weeks of buffer is the floor. Cleaner codebases shave two weeks off. Sites with more custom modules need twelve.

Is the cost of running shadow traffic worth it?

Yes. The discrepancies it surfaces are the ones that would have caused an outage on cutover day. Two weeks of dual-writing pays for itself the first time it catches a silent AFAS retry or a cache-key collision.

What is the biggest risk during the cutover itself?

Cache keys. Legacy systems hash things in ways nobody remembers. We always log the cache key alongside the order payload so any divergence is visible the moment it happens, not after a customer complains.

migrationmagentolegacy sitese-commercephparchitecture

Building something?

Start a project