← Blog

Magento

Magento 1.9 to Medusa: an eight-week B2B portal cutover

A 13-year-old Magento 1.9 portal, 28,400 tiered prices per dealer, an EDI feed into 140 builders, and eight weeks to land it on Medusa without losing a single order.

Jacob Molkenboer· Founder · A Brand New Company· 28 Apr 2026· 9 min
Closed leather ledger with linen tie, brass tag with green ribbon, iron key, wax-sealed envelope, red stamp on ivory paper.

The Alkmaar warehouse closes at 17:30. By 17:45 the dealer portal gets hit by thirty-eight garagebedrijven placing tomorrow's orders, and by 18:00 the EDI batch to 140 bouwbedrijven needs to leave the building. The portal runs on Magento 1.9 and PHP 5.6. Both have been past end of life for years. The CTO knows this. The owner knows this. The insurer has started asking pointed questions about the credit-limit logic that lives in a 900-line PHP file no one has touched since 2017.

This is the migration we ran over eight weeks last spring. The constraints were tighter than the headline: 28,400 staffel-prijzen per garage account, a credit-limit calculation under the Wet ketenaansprakelijkheid, and a Ketenstandaard SALES005-EDI feed that 140 builders' ERPs treat as canonical. Here is the playbook, in the order we used it.

Choosing Medusa over Magento 2

Easy question first. Magento 2 was the path of least architectural resistance: same vendor, similar mental model, a documented upgrade path. We did not pick it. Three reasons.

One: licensing math. The client did not need Adobe Commerce, and the open-source edition has thinned visibly since 2022. Magento 1 to Magento 2 is, in practice, a reimplementation of every custom module anyway. You keep the data; you throw out the code.

Two: the B2B logic was already in custom PHP. The staffel-prijzen engine and the credit-limit guard were not Magento features in any meaningful sense. They were a PHP layer that happened to live next to Magento. Moving them onto Medusa's plugin model was no harder than moving them onto Magento 2's module model, and the language was friendlier.

Three: the storefront was a SPA-ish thing held together with jQuery and prayer. Next.js gave us server components for the catalog and a clean place to put per-account pricing. For readers chewing on the same decision: Magento 1's official EOL was June 2020 (see Adobe's notice) and PHP 5.6 went EOL in December 2018. If your stack still sits here in 2026, you are not on borrowed time. You are on the time after that.

Step 1: inventory the actual surface area

First job: catalog what the old system does. Not what its docs say it does. What it does.

We pulled three things into a spreadsheet:

  • Every endpoint that returns a price — storefront API, admin AJAX, the EDI export job, an internal Excel exporter the sales team runs every Monday.
  • Every place credit-limit is read or written — checkout, the credit-applicatie form, the nightly batch that reconciles against the boekhouding.
  • Every consumer of the SALES005 feed — 140 builders' ERPs, plus two internal dashboards we found by grepping the Apache logs.

We ended up with 47 surface points across 7 jobs. Eleven of them were cron jobs running on a server no one had logged into since 2019. Two of those eleven still mattered.

Step 2: model staffel-prijzen as a price-list cascade

This is where most B2B Magento migrations get expensive. The naïve way to model 28,400 tiered prices per garage account is one fat table with a (sku, account_id, qty_min, qty_max, price) tuple. Across 380 active accounts that puts you north of ten million rows. Functional, slow, hellish to debug.

We modeled it as a cascade of Medusa price lists:

// price-list cascade for one dealer account
const cascade = [
  { id: "list:account:G-0142",  priority: 100, type: "override" },
  { id: "list:segment:premium", priority: 50,  type: "discount" },
  { id: "list:contract:2026Q2", priority: 25,  type: "contract" },
  { id: "list:base",            priority: 0,   type: "base" },
];

// resolve at request time
function resolvePrice(sku: string, qty: number, cascade: PriceList[]) {
  for (const list of cascade) {
    const tier = list.tiers.find(t =>
      t.sku === sku && qty >= t.qty_min && qty < t.qty_max
    );
    if (tier) return { price: tier.price, source: list.id };
  }
  throw new Error(`no price for ${sku} @ qty ${qty}`);
}

The cascade reduced the stored row count from 10.7M to about 412k. Most accounts inherit from segment plus contract; only ~180 of them carry account-specific overrides. The resolver runs in 1.4ms p99 against a hot Postgres.

Takeaway

If your tiered-price table is large enough to scare you, it is probably a cascade pretending to be a flat list. Find the layers before you scale the table.

Step 3: the credit-limit guard under Wet ketenaansprakelijkheid

Context for non-Dutch readers: the Wet ketenaansprakelijkheid (chain liability law, WKA) makes a main contractor liable for the unpaid taxes and social-security contributions of subcontractors in its chain. In practice, building-materials wholesalers running B2B credit need to track exposure per chain, not just per direct customer. The old PHP file did this by walking a reference table called keten_chain that nobody had documented.

We treated this as a domain service, not a Magento module. In Medusa it lives as a plugin that exposes one function and one event:

// services/credit-guard.ts
export class CreditGuardService {
  async assertOrderAllowed(accountId: string, total: number) {
    const exposure = await this.computeChainExposure(accountId);
    const limit    = await this.getEffectiveLimit(accountId);

    if (exposure + total > limit) {
      await this.events.emit("credit.blocked", {
        accountId, exposure, limit, attempted: total,
      });
      throw new CreditBlockedError({ exposure, limit });
    }
  }

  private async computeChainExposure(accountId: string) {
    // walk keten_chain depth-first, sum open invoices
    // capped at depth 4 — matches the boekhouder's rule of thumb
  }
}

Two things mattered here. First, the old logic blocked silently at the checkout button — the UI just greyed out. The new one throws a typed error that the storefront catches and renders honestly: "Je krediet-limiet is bereikt voor deze keten. Bel sales op …". Account managers stopped getting calls about the button not working.

Second, the boekhouder needed to audit the new calculation against the old one before going live. We ran both engines in parallel for three weeks. The old one was wrong on 14 accounts — by an average of €380. The new number was the right number. We told the client. They told their accountant. That conversation took longer than the code.

Step 4: wrapping, not rewriting, the SALES005-EDI bridge

Ketenstandaard's SALES005 is a Dutch B2B EDIFACT subset for building materials. Think SAP IDoc, but with worse documentation and 140 builders' ERPs each parsing it slightly differently.

We did not migrate the EDI generator. We wrapped it.

The old PHP cron is still running, today, generating SALES005 files. It now reads from a Postgres view that mirrors the Magento schema closely enough to be a drop-in:

-- compat view consumed by the legacy EDI generator
CREATE OR REPLACE VIEW magento_compat.sales_flat_order AS
SELECT
  o.id                      AS entity_id,
  o.display_id              AS increment_id,
  o.customer_id             AS customer_id,
  o.total / 100.0           AS grand_total,
  o.created_at,
  o.metadata->>'keten_id'   AS keten_chain_id
FROM medusa.order o
WHERE o.status IN ('captured','fulfilled');

We picked this trade-off deliberately. The SALES005 generator was 2,100 lines of well-tested PHP that 140 builders' systems already trusted. Rewriting it would have meant 140 separate UAT cycles. Wrapping it meant zero. The EDI feed sees Medusa as Magento. The 140 builders saw nothing change. Their ERPs kept ingesting the same line-end-delimited segments they had ingested for years. Ketenstandaard publishes the SALES005 reference; if you have a working generator that builders' systems trust, do not throw it away on principle.

Step 5: shadow traffic, not blue-green

This is the part most migration write-ups skip. Blue-green is the wrong pattern for a B2B dealer portal where the same account places the same order twice a day and the second order's tier depends on the first. The cutover risk is not the new system breaks. It is the new system gives the right answer for the wrong reason and we don't notice for four days.

We ran shadow traffic instead:

// proxy in front of magento + medusa
app.post("/api/*", async (req, res) => {
  const legacyP = forwardTo(LEGACY, req);
  const newP    = forwardTo(MEDUSA, structuredClone(req));

  const legacy = await legacyP;
  res.send(legacy); // user always sees legacy answer

  // fire-and-compare
  newP.then(neu => diffEngine.record(req, legacy, neu))
      .catch(err => diffEngine.recordError(req, err));
});

Every real production request hit both systems. The user only ever saw the legacy answer. A diff engine recorded every divergence to a Postgres table that the team triaged in a 25-minute daily standup.

Over four weeks the diff log went from about 6,000 daily divergences (mostly rounding and date-format noise) to 11, then to 2, then to 0 for three consecutive days. That zero-streak was our go-live signal. Not a date on a Gantt chart. A measured fact.

Step 6: the cutover itself

The actual cutover was 40 minutes on a Saturday at 06:00. Flip a DNS-level weight from 100/0 to 0/100. Watch the diff engine — now running in reverse, with Medusa as the source of truth — for an hour. Watch the SALES005 batch leave at 18:00. Done.

The legacy stack stayed warm for six weeks afterwards, read-only, for any data lookup the team wanted. We shut it off at the end of June.

What we would do differently

Two things.

One: we underestimated the keten_chain depth distribution. We capped at depth 4 because that was the boekhouder's rule of thumb. After go-live, three accounts turned out to need depth 5. Cheap to fix, but the kind of off-by-one that bites in week 9 when you thought you were done.

Two: the daily diff-engine standup should have started in week 1, not week 4. By the time we turned it on, several architectural decisions were already baked in that a daily look at real diffs would have caught earlier and cheaper.

What you can do today

If you are sitting on a Magento 1.9 portal in 2026, the smallest useful thing to do this afternoon: run php -v on the production box, write the number down next to its EOL date, and stick it on the wall of your engineering lead. That is the conversation the migration starts from.

When we built the Medusa cutover for the Alkmaar wholesaler, the thing that mattered most was not the new stack — it was the shadow-traffic diff log that turned is this safe? from a vibe into a number. If you are looking at a similar legacy migration, that is the pattern we would start with.

Key takeaway

Shadow traffic with a daily diff log turned 'is the new stack safe?' from a feeling into a number — that number, not a Gantt chart date, was the go-live signal.

FAQ

Why not just upgrade to Magento 2?

Magento 2 still required reimplementing every custom B2B module. Once we accepted that, the language (TypeScript vs. Magento DI) and the licensing math both pointed at Medusa.

How did you preserve the SALES005 EDI feed without rewriting it?

We kept the legacy PHP generator running and pointed it at a Postgres compatibility view that exposes Medusa orders in the shape Magento used to. The 140 builders' ERPs saw no change.

What is shadow traffic and why pick it over blue-green?

Both systems handle every real request; users only see the legacy answer; a diff engine logs divergences. It catches silently-wrong answers that blue-green hides until a user complains days later.

How long did the actual cutover window take?

Forty minutes on a Saturday at 06:00. The eight weeks of work before it were what made the forty minutes boring.

magentomigratione-commercelegacy sitesarchitectureintegrations

Building something?

Start a project