Magento
Magento 1.9 naar Medusa: B2B-portaal in acht weken live
Een 13 jaar oud Magento 1.9-portaal, 28.400 staffelprijzen per dealer, een EDI-feed naar 140 bouwers, en acht weken om het op Medusa te zetten zonder één order te verliezen.

Het magazijn in Alkmaar sluit om 17:30. Om 17:45 wordt het dealerportaal geraakt door achtendertig garagebedrijven die de orders voor morgen plaatsen, en om 18:00 moet de EDI-batch naar 140 bouwbedrijven het pand uit. Het portaal draait op Magento 1.9 en PHP 5.6. Allebei al jaren end of life. De CTO weet het. De eigenaar weet het. De verzekeraar stelt sinds kort lastige vragen over de krediet-limietlogica die in een PHP-bestand van 900 regels staat dat sinds 2017 niemand meer heeft aangeraakt.
Dit is de migratie die we afgelopen voorjaar in acht weken hebben gedaan. De randvoorwaarden waren strakker dan de kop suggereert: 28.400 staffelprijzen per garage-account, een krediet-limietberekening onder de Wet ketenaansprakelijkheid, en een Ketenstandaard SALES005-EDI-feed die de ERP's van 140 bouwers als canoniek behandelen. Hier is het draaiboek, in de volgorde waarin we het gebruikten.
Medusa kiezen boven Magento 2
Eerst de makkelijke vraag. Magento 2 was qua architectuur de weg van de minste weerstand: dezelfde leverancier, vergelijkbaar denkmodel, een gedocumenteerd upgradepad. We hebben er niet voor gekozen. Drie redenen.
Eén: licentierekensom. De klant had Adobe Commerce niet nodig, en de open-source-editie is sinds 2022 zichtbaar uitgedund. Magento 1 naar Magento 2 is in de praktijk toch al een herimplementatie van elke custom module. Je houdt de data; de code gooi je weg.
Twee: de B2B-logica zat al in custom PHP. De staffelprijzen-engine en de krediet-limietguard waren op geen enkele zinnige manier Magento-features. Het was een PHP-laag die toevallig naast Magento leefde. Die laag verplaatsen naar Medusa's pluginmodel was niet moeilijker dan naar Magento 2's modulemodel, en de taal was vriendelijker.
Drie: de storefront was een SPA-achtig ding dat met jQuery en goede bedoelingen bij elkaar werd gehouden. Next.js gaf ons server components voor de catalogus en een nette plek voor prijzen per account. Voor lezers die voor dezelfde keuze staan: Magento 1's officiële EOL was juni 2020 (zie de aankondiging van Adobe) en PHP 5.6 ging EOL in december 2018. Zit je stack in 2026 nog hier, dan ben je niet meer aan het uitstellen. Je zit ruim na dat uitstel.
Stap 1: de werkelijke oppervlakte in kaart brengen
Eerste klus: catalogiseren wat het oude systeem doet. Niet wat de docs zeggen dat het doet. Wat het doet.
We trokken drie dingen in een spreadsheet:
- Elk endpoint dat een prijs teruggeeft — storefront-API, admin-AJAX, de EDI-exportjob, een interne Excel-exporter die het salesteam elke maandag draait.
- Elke plek waar de krediet-limiet wordt gelezen of geschreven — checkout, het krediet-aanvraagformulier, de nachtelijke batch die afstemt tegen de boekhouding.
- Elke consumer van de SALES005-feed — de ERP's van 140 bouwers, plus twee interne dashboards die we vonden door de Apache-logs te greppen.
We eindigden op 47 oppervlaktepunten verdeeld over 7 jobs. Elf daarvan waren cronjobs op een server waar sinds 2019 niemand meer op had ingelogd. Twee van die elf deden er nog toe.
Stap 2: staffelprijzen modelleren als price-list cascade
Hier lopen de meeste B2B-Magento-migraties duur uit de hand. De naïeve manier om 28.400 staffelprijzen per garage-account te modelleren is één dikke tabel met een tuple (sku, account_id, qty_min, qty_max, price). Verdeeld over 380 actieve accounts kom je boven de tien miljoen rijen uit. Werkt, maar traag, en debugging is een hel.
Wij hebben het gemodelleerd als cascade van 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}`);
}
De cascade bracht de opgeslagen rijen terug van 10,7M naar ongeveer 412k. De meeste accounts erven van segment plus contract; slechts ~180 hebben account-specifieke overrides. De resolver draait in 1,4 ms p99 tegen een hot Postgres.
Is je staffelprijzentabel groot genoeg om je bang te maken, dan is het waarschijnlijk een cascade die zich voordoet als platte lijst. Vind de lagen voordat je de tabel opschaalt.
Stap 3: de krediet-limietguard onder de Wet ketenaansprakelijkheid
Context voor wie 'm niet kent: de Wet ketenaansprakelijkheid (WKA) maakt een hoofdaannemer aansprakelijk voor onbetaalde belastingen en sociale premies van onderaannemers in zijn keten. In de praktijk betekent dat: groothandels in bouwmaterialen die B2B-krediet geven moeten exposure per keten bijhouden, niet alleen per directe klant. Het oude PHP-bestand deed dit door een referentietabel keten_chain te doorlopen die niemand ooit had gedocumenteerd.
We behandelden dit als domeinservice, niet als Magento-module. In Medusa leeft het als plugin die één functie en één event blootstelt:
// 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
}
}
Twee dingen waren hier belangrijk. Eén: de oude logica blokkeerde stil bij de checkout-knop — de UI ging gewoon grijs. De nieuwe gooit een typed error die de storefront opvangt en eerlijk rendert: "Je krediet-limiet is bereikt voor deze keten. Bel sales op …". Accountmanagers werden niet meer gebeld over een knop die het niet deed.
Twee: de boekhouder wilde de nieuwe berekening vóór go-live afstemmen tegen de oude. We hebben beide engines drie weken parallel laten draaien. De oude zat fout op 14 accounts — gemiddeld €380 verschil. Het nieuwe getal was het juiste getal. We hebben het de klant verteld. Die heeft het zijn accountant verteld. Dat gesprek duurde langer dan de code.
Stap 4: de SALES005-EDI-bridge inkapselen, niet herschrijven
SALES005 van Ketenstandaard is een Nederlandse B2B-EDIFACT-subset voor bouwmaterialen. Denk SAP IDoc, maar met slechtere documentatie en 140 ERP's van bouwers die het elk net even anders parsen.
We hebben de EDI-generator niet gemigreerd. We hebben hem ingekapseld.
De oude PHP-cron draait vandaag de dag nog steeds en genereert SALES005-bestanden. Hij leest nu uit een Postgres-view die het Magento-schema dicht genoeg nabootst om drop-in te zijn:
-- 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');
Deze trade-off hebben we bewust gemaakt. De SALES005-generator was 2.100 regels goed-geteste PHP die de systemen van 140 bouwers al vertrouwden. Herschrijven zou 140 aparte UAT-rondes betekenen. Inkapselen betekende nul. De EDI-feed ziet Medusa als Magento. De 140 bouwers zagen niets veranderen. Hun ERP's bleven dezelfde regel-gescheiden segmenten innemen die ze al jaren innamen. Ketenstandaard publiceert de SALES005-referentie; heb je een werkende generator die systemen van bouwers vertrouwen, gooi 'm dan niet weg uit principe.
Stap 5: shadow traffic, geen blue-green
Dit is het deel dat de meeste migratieverhalen overslaan. Blue-green is het verkeerde patroon voor een B2B-dealerportaal waar hetzelfde account twee keer per dag dezelfde order plaatst en de staffel van de tweede order afhangt van de eerste. Het cutover-risico is niet het nieuwe systeem breekt. Het is het nieuwe systeem geeft het juiste antwoord om de verkeerde reden en we merken het vier dagen niet.
We draaiden in plaats daarvan shadow traffic:
// 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));
});
Elke echte productie-request raakte beide systemen. De gebruiker zag altijd alleen het legacy-antwoord. Een diff-engine schreef elke afwijking weg naar een Postgres-tabel die het team in een dagelijkse standup van 25 minuten triageerde.
Over vier weken zakte het diff-log van ongeveer 6.000 dagelijkse afwijkingen (vooral afrondings- en datumformaatruis) naar 11, daarna naar 2, en daarna naar 0 drie dagen op rij. Die nul-reeks was ons go-live-signaal. Geen datum op een Gantt-chart. Een gemeten feit.
Stap 6: de cutover zelf
De daadwerkelijke cutover duurde 40 minuten op een zaterdag om 06:00. Een gewicht op DNS-niveau omdraaien van 100/0 naar 0/100. Een uur lang de diff-engine in de gaten houden — nu omgekeerd, met Medusa als bron van waarheid. Om 18:00 de SALES005-batch zien uitgaan. Klaar.
De legacy-stack stond daarna nog zes weken warm, read-only, voor elke datalookup die het team wilde doen. Eind juni hebben we 'm uitgezet.
Wat we anders zouden doen
Twee dingen.
Eén: we hadden de dieptedistributie van keten_chain onderschat. We hadden op diepte 4 afgekapt omdat dat de vuistregel van de boekhouder was. Na go-live bleken drie accounts diepte 5 nodig te hebben. Goedkoop te fixen, maar precies het soort off-by-one dat in week 9 bijt, als je dacht dat je klaar was.
Twee: de dagelijkse standup over de diff-engine had in week 1 moeten beginnen, niet in week 4. Toen we hem aanzetten, lagen er al architecturale beslissingen vast die een dagelijkse blik op echte diffs eerder en goedkoper had opgevangen.
Wat je vandaag kunt doen
Zit je in 2026 op een Magento 1.9-portaal, dan is het kleinste nuttige ding dat je vanmiddag kunt doen: php -v draaien op de productieserver, het getal opschrijven naast de EOL-datum, en het op de muur van je engineering lead plakken. Daar begint het gesprek over de migratie.
Toen we de Medusa-cutover bouwden voor de Alkmaarse groothandel, was het belangrijkste niet de nieuwe stack — het was het shadow-traffic diff-log dat is dit veilig? veranderde van een gevoel in een getal. Sta je voor een vergelijkbare legacy-migratie, dan is dat het patroon waarmee wij zouden beginnen.
Kern
Shadow traffic met een dagelijks diff-log veranderde 'is de nieuwe stack veilig?' van een gevoel in een getal — dat getal, niet een datum op een Gantt-chart, was het go-live-signaal.
FAQ
Waarom niet gewoon upgraden naar Magento 2?
Magento 2 vereiste alsnog herimplementatie van elke custom B2B-module. Toen we dat eenmaal accepteerden, wezen zowel de taal (TypeScript vs. Magento DI) als de licentierekensom richting Medusa.
Hoe hielden jullie de SALES005-EDI-feed overeind zonder herschrijven?
We lieten de legacy PHP-generator draaien en richtten hem op een Postgres-compatibiliteitsview die Medusa-orders blootstelt in de vorm die Magento gebruikte. De ERP's van de 140 bouwers zagen geen verschil.
Wat is shadow traffic en waarom kiezen boven blue-green?
Beide systemen verwerken elke echte request; gebruikers zien alleen het legacy-antwoord; een diff-engine logt afwijkingen. Het vangt stil-foute antwoorden die blue-green pas zichtbaar maakt als een gebruiker dagen later klaagt.
Hoe lang duurde het feitelijke cutover-venster?
Veertig minuten op een zaterdag om 06:00. De acht weken werk ervoor maakten die veertig minuten saai.