Magento
Van Magento 1.7 naar Saleor + Remix: B2B-cutover in vijf weken
Een technische handel uit Deventer met 26 man draaide hun B2B-catalogus vijftien jaar op Magento 1.7. Dit is de shadow-cutover van vijf weken die ze eraf kreeg.

De orderdesk in Deventer krijgt om 09:14 op een dinsdag een telefoontje. Een inkoper bij een regionaal installatiebedrijf wil 240 meter kabelgoot, dezelfde SKU die hij sinds 2011 bestelt. Hij krijgt een andere prijs dan een aannemer 40 kilometer ten oosten van hem, omdat zijn inkoopcombinatie twee CEO's geleden een staffel uitonderhandelde, en die staffel zit nog steeds in een EAV-tabel in Magento 1.7 die al zeven jaar niemand meer heeft aangeraakt.
De klant is een technische handelsfirma met 26 man — bevestigingsmateriaal, kabelgoten, schakelmateriaal, het soort leverancier dat het meeste volume via telefoon en bestelportaal draait, niet via webshop-checkout. Hun site liep sinds 2011 op Magento 1.7 met daarbovenop een dikke laag custom PHP 5.4. Het werkte. Het werkte zoals een oude diesel werkt: luid, lekkend, maar hij komt in de haven aan. Het besluit om te migreren kwam niet uit een feature-request. Het kwam doordat hun hostingpartner end-of-life-patches voor PHP 5.4 uit het rek trok.
Dit is de shadow-traffic-cutover van vijf weken die ze op Saleor + Remix kreeg, zonder één staffelprijs, één kredietlimiet of één AFAS-syncvenster te verliezen.
Waarom Magento 1.7 het tot 2026 volhield
Adobe stopte in juni 2020 met security-support voor Magento 1. De officiële aankondiging van Adobe staat nog online. De helft van de bureaus die de klant tussen 2020 en 2024 polste, wilde from scratch herbouwen op Magento 2 of Shopify Plus. De klant zei elke keer nee, om dezelfde drie redenen.
Hun orderdesk draaide op spiergeheugen binnen de Magento-admin. Hun finance-team had twee macro's die rechtstreeks uit een custom PHP-rapportage-endpoint lazen. En hun complete B2B-prijslogica, 18.200 staffelregels over 47 inkoopcombinaties, zat verstopt in een kluwen EAV-attributen en een custom module genaamd Klantgroep_Staffel die één developer in 2013 schreef en een ander in 2018 patchte.
Een herbouw betekende die kennis migreren, niet weggooien. Dus we namen de opdracht aan: dezelfde data, dezelfde prijzen voor dezelfde klanten, hetzelfde AFAS-syncvenster, moderne stack, geen checkout-downtime.
Drie randvoorwaarden die de migratie bepaalden
Voordat we ook maar één architectuurkeuze maakten, schreven we op welke drie dingen niet mochten breken.
Staffelprijzen per inkoopcombinatie. 18.200 regels. Elke regel is een (SKU, klantgroep, minimum-aantal, prijs, geldig-vanaf, geldig-tot)-tuple, maar de klantgroep-as is twee niveaus diep: elke klant hoort bij een inkoopcombinatie, elke combinatie heeft een basisstaffel, en individuele klanten kunnen specifieke regels overrulen. Verlies dit en de orderdesk pakt maandagochtend huilend de telefoon op.
Kredietlimiethistorie per klant. Niet alleen de huidige limiet, de volledige tijdlijn. Finance gebruikt de historie om met klanten te discussiëren ("je zat in maart op €40k, in juni hebben we naar €55k opgehoogd, nu vraag je €70k"). Het staat in een custom klant_krediet_log-tabel met 14 jaar aan regels.
AFAS Profit nachtelijke artikelsync. AFAS pusht elke nacht om 02:00 de canonieke artikelstam naar de webshop. Voorraadniveaus, EAN-codes, vervangings-SKU-pointers, levertijden van leveranciers. Als de nieuwe stack die payload niet binnen hetzelfde venster verwerkt, kan het magazijn de volgende ochtend geen orders pikken.
Elke architectuurkeuze hierna werd door die drie gefilterd.
Shadow traffic, week voor week
Een shadow-traffic-cutover betekent dat de nieuwe stack parallel aan de oude draait en een gespiegelde kopie van elk echt request binnenkrijgt, maar zijn antwoorden bereiken de gebruiker nooit. Je diff oud versus nieuw zo lang als nodig is om de nieuwe stack te vertrouwen. Daarna flip je.
Het schema van vijf weken zag er zo uit.
Week 1, mirror, geen vergelijking. We zetten een kleine reverse-proxy voor de live Magento 1.7. Elke GET ging zoals gewend naar Magento, en een duplicaat vuurde-en-vergat op de Saleor + Remix-stack op een parallel domein. Saleor's GraphQL-log werd geramd. We keken nog niet naar correctheid, alleen naar 'blijft de nieuwe stack overeind onder de echte traffic-vorm.'
Week 2, read-side diff. Dezelfde mirror, nu met een response-vergelijker. Voor productpagina's, categoriepagina's en klantspecifieke prijs-lookups hashten we de canonieke velden (prijs, beschikbare voorraad, geldende staffel-ID, vervangende SKU) van beide responses en logden de diffs naar een Postgres-tabel. We diffden geen HTML. Die kant op ligt waanzin. We diffden de vorm van de data waarmee de pagina werd gerenderd.
Week 3, write-side dual-run. Een product aan het winkelmandje toevoegen in Magento triggerde een parallelle call tegen Saleor's checkout-API, die direct daarna werd teruggerold. Het ging erom het schrijfpad te belasten met echte sessiedata, echte cookies, echte actiecodes, echte B2B-klant-ID's, zonder ooit te committen.
Week 4, interne cutover voor de orderdesk. De 8 binnendienstmedewerkers stapten voor hun eigen werk over op het nieuwe Remix-orderportaal. Externe klanten gingen nog steeds naar Magento. Hier vind je de bugs die alleen voor power users uitmaken: keyboard shortcuts, default sorteervolgordes, hoe een offerte-PDF afrondt.
Week 5, publieke cutover met rollback. De reverse-proxy begon 1% van het echte klantverkeer naar Saleor te routeren, daarna 10%, daarna 50%, daarna 100% over zes dagen. Magento bleef de hele tijd warm, met de optie binnen een minuut terug te flippen.
Een shadow-cutover beschermt je niet tegen bugs. Hij beschermt je tegen de bugs die je niet kent. Het diff-log is het deliverable.
De staffel-boom in kaart brengen vóór het eerste migratiescript
De verleiding bij verouderde B2B-prijslogica is om het migratiescript als eerste te schrijven en het datamodel te ontdekken door te falen. Niet doen. De eerste acht werkdagen deden we niets anders dan met een notitieblok open de Magento-database lezen.
De staffel-hiërarchie viel uiteen in drie niveaus:
inkoopcombinatie → klantgroep → klantElk niveau kon het niveau erboven per SKU overrulen. De catch: de override-semantiek was niet consistent. Soms verving een klant-niveau-regel de klantgroep-regel volledig. Soms voegde 'm er een staffelstap bovenop toe. Het gedrag hing af van een flag-kolom genaamd mode die ofwel replace, add, of NULL was. NULL betekende 'vraag het aan de Klantgroep_Staffel-module', die een hard-coded fallback had naar add voor SKU's die met CB- begonnen en replace voor al het andere.
Niemand wist dit. We leerden het door deze extractie-query te schrijven en het resultaat te diffen tegen het 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;Zodra resolved_mode een expliciete kolom was, konden we de regels mappen op Saleor's price-list-model. Saleor levert geen native B2B-prijshiërarchie van drie niveaus, dus we modelleerden 'm met een keten van customer-group-scoped price lists, in volgorde geëvalueerd, met een custom price calculator plugin die mode respecteerde. De Saleor plugin-docs zijn end-to-end de moeite waard voor je aan deze aanpak begint.
Kredietlimieten als event log
De Magento klant_krediet_log-tabel had 14 jaar aan regels. De naïeve migratie is: lees de meest recente regel per klant, schrijf 'm naar een credit_limit-kolom op het Saleor-klantrecord. Niet doen.
Finance heeft de historie nodig. We migreerden het log als een 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" },
});
}De huidige limiet is een query tegen het log, geen gedenormaliseerd veld. Dit voegt een milliseconde toe aan checkout. Het betekent ook dat als finance met een klant discussieert over waarom hun limiet in 2022 daalde, het antwoord één Remix loader verwijderd is.
AFAS Profit en de artikelsync van acht uur
De nachtelijke sync met AFAS Profit is in onze ervaring wat meer B2B-migraties sloopt dan al het front-end-werk samen. AFAS pusht tussen 02:00 en 03:00 een payload van meerdere megabytes XML — volledige artikelstam, voorraad, prijzen in lijst, levertijden. De verouderde Magento-module verwerkte 'm in acht uur cron-gemaal. Om 11:00 pikte het magazijn al tegen achterhaalde voorraadcijfers.
Saleor's ingestie-pad gebruikt een GraphQL bulk-mutation-patroon, maar je kunt 80.000 artikelen niet rij voor rij erdoorheen pijpen. We landden hierop:
# 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))Acht parallelle workers, 500 artikelen per batch, idempotente updates met het AFAS-artikelnummer als sleutel. De volledige sync draait in 22 minuten. Het magazijn heeft verse voorraad voor de eerste shift-koffie.
Als je AFAS-connector midden in een batch sterft, moet je dezelfde payload veilig opnieuw kunnen draaien. Maak elke mutatie idempotent op de AFAS-sleutel, niet op het interne Saleor-ID. Het interne ID kan veranderen. De AFAS-sleutel niet.
Cutover-weekend
De daadwerkelijke cutover verliep zonder incidenten, wat het hele punt is van vijf weken shadow traffic. Zaterdag 06:00 flipten we de reverse-proxy om 100% van het verkeer naar Saleor + Remix te sturen. Magento bleef draaien, read-only, met een banner in de admin: 'dit is het archief, plaats orders in het nieuwe portaal.' Twee mensen van de orderdesk werkten op de vloer voor het geval er iets misging.
Niets ging mis. Het diff-log van week 2 had de zeven edge cases die we hadden gemist al naar boven gehaald. De meeste in de staffel-mode-logica, twee in de weergave van de kredietgeschiedenis, één in hoe AFAS een uit de handel genomen SKU representeerde. Tegen het cutover-weekend stonden die allemaal op groen.
De grootste verrassing was niet technisch. De orderdesk vroeg dinsdag of we de oude Magento 'voor altijd read-only konden maken en gewoon laten staan.' Dat hebben we gedaan. Hij draait op één kleine VM, achter basic auth, zonder PHP-schrijfpad. Hij is nu een 15-jarig archief dat finance nog steeds kan bevragen wanneer ze het nodig hebben. Het oude systeem volledig afschieten is zelden de juiste keuze als een B2B er 14 jaar spiergeheugen op heeft zitten.
Wat we anders zouden doen
Twee dingen.
Eén, we hebben in week 2 te lang geprobeerd gerenderde HTML te diffen, voordat we toegaven dat het diffen van de data waarmee de pagina rendert het enige relevante diff is voor een headless herbouw. Als we dit opnieuw deden, zouden we vanaf dag één gestructureerde velden diffen en pas in week 4 naar de gerenderde output kijken.
Twee, het AFAS-syncvenster is de randvoorwaarde die de hele herbouwplanning hoort te sturen, niet de front-end. We schoven AFAS-werk door naar week 3 omdat het minder risicovol leek. Dat was het niet. Als de nachtelijke sync niet binnen is voordat het magazijn de eerste order pikt, doet de rest er niet toe.
Toen we de Saleor + Remix-vervanger voor de Deventerse firma bouwden, was het ding waar we het hardst tegenaan liepen die ambiguïteit in de staffel-mode op drie niveaus, ongedocumenteerd gedrag waar miljoenen euro's aan jaarlijks ordervolume vanaf hingen. Uiteindelijk losten we het op door de resolutie expliciet te maken in de extractie-query nog vóór er één byte Saleor binnenkwam, en dat soort werk doen we bij elke legacy-migratie die we aannemen.
Sta je op dit moment naar een Magento 1.x-admin te kijken, dan is het kleinste nuttige dat je vandaag kunt doen: open de database en draai één query die elke custom-tabel oplijst die niet in het standaard Magento 1.7-schema voorkomt. Die lijst is je migratie-scope. De rest is detail.
Kern
Een shadow-traffic-cutover beschermt je niet tegen bugs. Hij beschermt je tegen de bugs die je niet kent, en het diff-log is het deliverable.
FAQ
Waarom niet herbouwen op Magento 2 of Adobe Commerce?
Adobe Commerce houdt je in hetzelfde EAV-en-module-ecosysteem dat de klant wilde verlaten. Voor een B2B met custom staffellogica en een strakke ERP-sync is een headless commerce + framework-split makkelijker te doorgronden en goedkoper te onderhouden.
Hoe lang moet de oude Magento online blijven als read-only archief?
Zo lang finance 'm nog gebruikt. Wij laten 'm read-only achter basic auth op een kleine VM staan, zonder PHP-schrijfpad. Kost minder dan een euro per dag en beantwoordt vragen waarvoor je anders een database-restore nodig hebt.
Waarom Remix in plaats van Next.js voor de storefront?
Remix loaders mappen schoon op Saleor's GraphQL-queries, het form-action-model past bij B2B order-entry-workflows, en de per-route data fetching houdt klantspecifieke prijs-lookups buiten een gedeelde cache.
Wat trok de verouderde AFAS-sync naar acht uur?
Rij-voor-rij inserts binnen één PHP-cron-proces, zonder batching en zonder concurrency. Stap over op bulk mutations, parallelliseer met een semaphore, en sleutel idempotentie aan op het AFAS-artikelnummer, niet op het interne ID.