← Blog

Magento

Magento 1.7 to Saleor + Remix: a five-week B2B cutover

A 26-person technische handel in Deventer ran their B2B catalog on Magento 1.7 for fifteen years. Here is the five-week shadow cutover that got them off it.

Jacob Molkenboer· Founder · A Brand New Company· 18 Jun 2026· 9 min
Worn leather ledger half-open on ivory paper, brass shipping tag on linen twine, green index card, broken red wax seal.

The order desk in Deventer takes a call at 09:14 on a Tuesday. A buyer at a regional installations firm wants 240 metres of cable tray, the same SKU he has been ordering since 2011. He gets a different price than a contractor 40 kilometres east of him, because his inkoopcombinatie negotiated a staffel two CEOs ago, and that staffel still lives in a Magento 1.7 EAV table that nobody has touched in seven years.

The client is a 26-person technische handelsfirma — fasteners, cable trays, switchgear, the kind of supplier that does most of its volume by phone and order portal, not webshop checkout. Their site had been running on Magento 1.7 with a thick custom-PHP 5.4 layer on top since 2011. It worked. It worked the way an old diesel works: loud, leaking, but it gets to the harbour. The decision to migrate did not come from a feature request. It came from their hosting partner pulling end-of-life PHP 5.4 patches off the rack.

This is the five-week shadow-traffic cutover that got them onto Saleor + Remix without losing a single staffelprijs, a single kredietlimiet, or a single AFAS sync window.

Why Magento 1.7 survived to 2026

Adobe ended security support for Magento 1 in June 2020. Adobe's official notice is still up. Half the agencies the client pitched between 2020 and 2024 wanted to rebuild from scratch on Magento 2 or Shopify Plus. The client said no every time, for the same three reasons.

Their order desk ran on muscle memory inside the Magento admin. Their finance team had two macros that read directly from a custom PHP report endpoint. And their entire B2B pricing logic, 18,200 staffel rules across 47 inkoopcombinaties, was encoded in a tangle of EAV attributes and a custom module called Klantgroep_Staffel that one developer wrote in 2013 and another patched in 2018.

A rebuild meant migrating that knowledge, not throwing it away. So we took the brief: same data, same prices for the same customers, same AFAS sync window, modern stack, no checkout downtime.

Three constraints that defined the migration

Before any architecture, we wrote down the three things that could not break.

Staffelprijzen per inkoopcombinatie. 18,200 rules. Each one is a (SKU, customer-group, minimum-quantity, price, valid-from, valid-until) tuple, but the customer-group axis is two levels deep: every customer belongs to an inkoopcombinatie, every combination has a base staffel, and individual customers can override specific lines. Lose this and the order desk picks up the phone in tears on Monday morning.

Kredietlimiet history per customer. Not just the current limit, the full timeline. Finance uses the history to argue with customers ("you were at €40k in March, we increased to €55k in June, you are now requesting €70k"). It lives in a custom klant_krediet_log table with 14 years of rows.

AFAS Profit nightly artikelsync. AFAS pushes the canonical article master to the webshop every night at 02:00. Stock levels, EAN codes, replacement-SKU pointers, supplier lead times. If the new stack cannot accept that payload in the same window, the warehouse cannot pick orders the next morning.

Every architectural decision after this point was filtered through those three.

Shadow traffic, week by week

A shadow-traffic cutover means the new stack runs in parallel with the old one, receiving a mirrored copy of every real request, but its responses never reach the user. You diff old vs. new for as long as you need to trust the new system. Then you flip.

The five-week schedule looked like this.

Week 1, mirror, no comparison. We put a small reverse-proxy in front of the live Magento 1.7. Every GET went to Magento as normal, and a duplicate fired and-forgot at the Saleor + Remix stack running on a parallel domain. Saleor's GraphQL log got hammered. We were not looking at correctness yet, only at "does the new stack stay up under real traffic shape."

Week 2, read-side diff. Same mirror, now with a response comparator. For product pages, category pages, and customer-specific price lookups, we hashed the canonical fields (price, available stock, applicable staffel ID, replacement SKU) on both responses and logged the diffs to a Postgres table. We did not diff HTML. That way lies madness. We diffed the shape of the data the page rendered from.

Week 3, write-side dual-run. Adding an order to the cart on Magento triggered a parallel call against Saleor's checkout API, then immediately rolled it back. The point was to exercise the write path with real session data, real cookies, real promotion codes, real B2B customer IDs, without ever committing.

Week 4, internal cutover for the order desk. The 8 inside-sales people switched to the new Remix-based order portal for their own work. External customers still hit Magento. This is where you find the bugs that only matter to power users: keyboard shortcuts, default sort orders, the way a quote PDF rounds.

Week 5, public cutover with rollback. The reverse-proxy started routing 1% of real customer traffic to Saleor, then 10%, then 50%, then 100% over six days. Magento stayed warm the whole time, with the option to flip back in under a minute.

Takeaway

A shadow cutover does not save you from bugs. It saves you from the bugs you do not know about. The diff log is the deliverable.

Mapping the staffel-tree before the first migration script

The temptation with legacy B2B pricing is to write the migration script first and discover the data model by failing. Do not. We spent the first eight working days doing nothing but reading the Magento database with a notebook open.

The staffel hierarchy reduced to three levels:

inkoopcombinatie → klantgroep → klant

Each level could override the level above on a per-SKU basis. The catch: the override semantics were not consistent. Sometimes a klant-level rule replaced the klantgroep rule entirely. Sometimes it added another staffel-step on top. The behaviour depended on a flag column called mode that was either replace, add, or NULL. NULL meant "ask the Klantgroep_Staffel module," which had a hard-coded fallback to add for SKUs starting with CB- and replace for everything else.

Nobody knew this. We learned it by writing this extraction query and diffing the result against the Magento order-line audit log:

SELECT
  cps.sku,
  cps.combinatie_id,
  cps.klantgroep_id,
  cps.klant_id,
  cps.min_qty,
  cps.prijs,
  COALESCE(cps.mode, CASE
    WHEN cps.sku LIKE 'CB-%' THEN 'add'
    ELSE 'replace'
  END) AS resolved_mode,
  cps.valid_from,
  cps.valid_until
FROM klantgroep_staffel cps
WHERE cps.valid_until IS NULL
   OR cps.valid_until >= CURDATE()
ORDER BY cps.combinatie_id, cps.klantgroep_id, cps.klant_id, cps.sku, cps.min_qty;

Once resolved_mode was an explicit column, we could map the rules into Saleor's price-list model. Saleor does not ship a native three-level B2B price hierarchy, so we modelled it with a chain of customer-group-scoped price lists, evaluated in order, with a custom price calculator plugin that respected mode. The Saleor plugin docs are worth reading end-to-end before you commit to this approach.

Credit limits as an event log

The Magento klant_krediet_log table had 14 years of rows. The naive migration is: read the most recent row per customer, write it to a credit_limit column on the Saleor customer record. Do not.

Finance needs the history. We migrated the log as a log:

// remix-app/app/lib/credit-log.server.ts
import { db } from "./db.server";

export type CreditEvent = {
  customerId: string;
  limit: number;
  effectiveAt: Date;
  setBy: string;
  reason: string | null;
};

export async function currentLimit(customerId: string): Promise<number> {
  const row = await db.creditEvent.findFirst({
    where: { customerId, effectiveAt: { lte: new Date() } },
    orderBy: { effectiveAt: "desc" },
  });
  return row?.limit ?? 0;
}

export async function limitHistory(customerId: string): Promise<CreditEvent[]> {
  return db.creditEvent.findMany({
    where: { customerId },
    orderBy: { effectiveAt: "desc" },
  });
}

The current limit is a query against the log, not a denormalised field. This adds a millisecond to checkout. It also means that when finance argues with a customer about why their limit dropped in 2022, the answer is one Remix loader away.

AFAS Profit and the eight-hour artikelsync

The AFAS Profit nightly sync is the thing that, in our experience, kills more B2B migrations than the front-end work combined. AFAS pushes a multi-megabyte XML payload — full article master, stock, pricing in-list, lead times — between 02:00 and 03:00. The legacy Magento module ingested it in eight hours of cron grinding. By 11:00 the warehouse was already picking against stale stock numbers.

Saleor's ingestion path uses a GraphQL bulk-mutation pattern, but you cannot pipe 80,000 articles through it row-by-row. We landed on this:

# saleor-app/sync_afas.py
import asyncio
from saleor_sdk import SaleorClient

BATCH = 500

async def sync_articles(payload):
    client = SaleorClient(url=SALEOR_URL, token=APP_TOKEN)
    batches = [payload[i:i + BATCH] for i in range(0, len(payload), BATCH)]

    sem = asyncio.Semaphore(8)  # AFAS does not like more than 8 parallel
    async def push(batch):
        async with sem:
            await client.product_bulk_update(batch)

    await asyncio.gather(*(push(b) for b in batches))

Eight parallel workers, 500 articles per batch, idempotent updates keyed on the AFAS article number. The full sync runs in 22 minutes. The warehouse has fresh stock before the first shift coffee.

If your AFAS connector dies mid-batch, you must be able to re-run the same payload safely. Make every mutation idempotent on the AFAS key, not on the Saleor internal ID. The internal ID can change. The AFAS key cannot.

Cutover weekend

The actual cutover was uneventful, which is the whole point of five weeks of shadow traffic. Saturday 06:00 we flipped the reverse-proxy to send 100% of traffic to Saleor + Remix. Magento stayed up, read-only, with a banner on the admin saying "this is the archive, place orders in the new portal." Two members of the order desk worked the floor in case anything blew up.

Nothing did. The diff log from week 2 had already surfaced the seven edge cases we had missed. Most of them in the staffel mode logic, two in the credit history rendering, one in how AFAS represented a discontinued SKU. By cutover weekend, those were all green.

The biggest surprise was a non-technical one. The order desk asked, on Tuesday, whether we could "make the old Magento read-only forever and just leave it there." We did. It runs on a single small VM, behind basic auth, with no PHP write path. It is now a 15-year archive that finance can still query when they need to. Killing the old system entirely is rarely the right move when a B2B has 14 years of muscle memory on it.

What we would do differently

Two things.

First, we spent too long in week 2 trying to diff rendered HTML before we admitted that diffing the data the page renders from is the only diff that matters for a headless rebuild. If we did this again, we would diff structured fields from day one and never look at rendered output until week 4.

Second, the AFAS sync window is the constraint that should drive the whole rebuild timeline, not the front-end. We pushed AFAS work to week 3 because it felt less risky. It was not. If the nightly sync does not land before the warehouse picks the first order, nothing else matters.

When we built the Saleor + Remix replacement for the Deventer firm, the thing we ran into hardest was that three-level staffel mode ambiguity, undocumented behaviour with millions of euros of annual order volume riding on it. We ended up solving it by making the resolution explicit in the extraction query before a single byte hit Saleor, which is the kind of work we do on every legacy migration we take on.

If you are staring at a Magento 1.x admin right now, the smallest useful thing you can do today is open the database and run a single query that lists every custom table not in the stock Magento 1.7 schema. That list is your migration scope. Everything else is detail.

Key takeaway

A shadow-traffic cutover does not save you from bugs. It saves you from the bugs you do not know about, and the diff log is the deliverable.

FAQ

Why not rebuild on Magento 2 or Adobe Commerce instead?

Adobe Commerce keeps you inside the same EAV-and-module ecosystem the client wanted to leave. For a B2B with custom staffel logic and a tight ERP sync, a headless commerce + framework split is easier to reason about and cheaper to maintain.

How long should the old Magento stay online as a read-only archive?

As long as finance still uses it. We leave it read-only behind basic auth on a small VM with no PHP write path. It costs less than a euro a day and answers questions that would otherwise need a database restore.

Why Remix instead of Next.js for the storefront?

Remix loaders map cleanly onto Saleor's GraphQL queries, the form-action model fits B2B order-entry workflows, and the per-route data fetching keeps customer-specific price lookups out of a shared cache.

What killed the legacy AFAS sync at eight hours?

Row-by-row inserts inside a single PHP cron process with no batching and no concurrency. Move to bulk mutations, parallelise with a semaphore, and key idempotency on the AFAS article number, not the internal ID.

magentomigrationlegacy sitese-commercearchitecturecase study

Building something?

Start a project