124 —Migration
Van osCommerce naar WooCommerce: zes weekenden, SEO intact
Een osCommerce-shop van 14 jaar oud in zes weekenden omgezet naar WooCommerce. De URL-map, de specials-naar-sale-price brug en de SEO die bleef staan.
De eerste vrijdag van maart stuurde een Nederlands bureau waar we mee werken een screenshot door van een Google Search Console-grafiek die eindelijk de bodem had geraakt. De osCommerce-shop van hun klant draaide sinds 2010. De hostingmaatschappij had de server stilletjes naar PHP 7.4 getild. Bij de checkout vlogen er fatals langs als klanten via Mollie met Apple Pay betaalden. Het bureau wachtte al zes maanden op een fatsoenlijk budget. De klant belde op een donderdag: kies een stack, geef ons een bedrag, voor Black Friday staat het live.
Wij kozen WooCommerce. Niet omdat het het juiste antwoord is voor elke legacy site, maar omdat de catalogus (ongeveer 2.400 producten, 38 categorieën) niet groot genoeg was om een headless setup te rechtvaardigen, het team al WordPress draaide voor de marketingpagina's, en we tien jaar aan geïndexeerde productpagina's moesten behouden zonder dat iemand een nieuwe admin hoefde te leren. Zes weekenden, twee engineers, één staging-kloon. Dit is wat er bij de osCommerce-naar-WooCommerce-migratie echt toe deed.
De URL-map
osCommerce-URL's lezen als archeologie. /product_info.php?products_id=842 is een productdetailpagina; /index.php?cPath=22_28 is een subcategorie. Beide draaien op query strings, beide verliezen elk signaal van waar de pagina over gaat, en beide staan veertien jaar in de Google-index. Je kunt ze niet naar een 404 redirecten en de schade incasseren.
We dumpten de products-tabel op staging en bouwden een CSV: products_id, products_name, de slug die SEO Booster ooit had gegenereerd indien aanwezig, categories_id, target Woo-slug. Ongeveer 100 regels PHP lazen het uit; ongeveer 300 regels handwerk maakten het schoon. Daarna ging dit .htaccess-blok live, ingekort:
RewriteEngine On
# Old product URLs
RewriteCond %{QUERY_STRING} ^products_id=([0-9]+)$
RewriteRule ^product_info\.php$ /redir/product/%1? [R=301,L]
# Old category URLs
RewriteCond %{QUERY_STRING} ^cPath=([0-9_]+)$
RewriteRule ^index\.php$ /redir/category/%1? [R=301,L]
# Old manufacturer URLs
RewriteCond %{QUERY_STRING} ^manufacturers_id=([0-9]+)$
RewriteRule ^index\.php$ /redir/brand/%1? [R=301,L]
Let op dat de redirects in een /redir/-namespace landen, niet direct op de live Woo-slug. Die namespace is een kleine WordPress route handler die het legacy-ID opzoekt in een map die in wp_options staat en vervolgens met een 301 doorstuurt naar de huidige URL. Als een productslug na launch wijzigt (en dat gebeurt altijd), raken we .htaccess niet aan; we updaten de map.
Drie redenen voor die tussenstap. De managed WordPress-host van het bureau geeft geen toegang tot de hoofd-Apache-config, dus RewriteMap was geen optie. De legacy-IDs zijn niet aaneengesloten, en we wilden zes maanden later nog steeds server-side kunnen zien welke oude URL's nog werden geraakt. En als product 842 in 2027 uit het assortiment gaat, willen we het met een 301 naar de bijbehorende categoriepagina sturen, niet naar een magere 404.
De map verifiëren
We trokken de top 5.000 organische landingspagina's van de afgelopen twaalf maanden door een curl-loop, met de verwachting van een 301 naar een 200-pagina of een bewuste 410. De eerste run gaf in 7% van de gevallen een 200, maar dan naar het verkeerde product, allemaal in categorieën waar de oude admin jaren eerder stilletjes products_id-waarden had hergebruikt. Een middag werk om recht te trekken. We hebben het eindrapport als CSV opgeslagen en in de repo gecommit. Niemand wil in oktober ontdekken dat een belangrijke categorie sinds mei stilletjes stuk is.
De brug voor prijsregels
osCommerce noemt ze "specials." Een rij in de specials-tabel zet een vaste prijs en een einddatum op een products_id. Er is geen concept van "10% korting op alles in deze categorie tot vrijdag." De shop had bij cutover 312 actieve specials en nog 4.100 historische die de marketingafdeling wilde behouden voor analyse.
WooCommerce gebruikt _sale_price en _sale_price_dates_from / _sale_price_dates_to op de post meta van het product. De brug is mechanisch saai: koppel products_id aan post_id, kopieer de actieprijs, kopieer de einddatum.
INSERT INTO wp_postmeta (post_id, meta_key, meta_value)
SELECT
m.wp_post_id,
'_sale_price',
s.specials_new_products_price
FROM legacy_specials s
JOIN id_map m ON m.legacy_id = s.products_id
WHERE s.status = 1;
De SQL was niet het lastige deel. Het lastige deel was het gesprek erna. De marketingmanager wilde voortaan kortingen op percentages, want zo had ze haar campagnes al jaren in haar hoofd zitten. Met osCommerce ging dat niet, dus rekende ze al een decennium lang acties met de hand uit in een spreadsheet. We wezen haar op een kleine plugin die percentageregels op categorieniveau toevoegt en liepen samen één campagne door. De spreadsheet verhuisde naar een map die /archive/ heet en bleef daar.
Voor de 4.100 historische specials maakten we een aparte tabel legacy_specials_archive met een admin-view van één regel. Na twee maanden had ze hem precies één keer geopend. We lieten het staan; de migratie ging om haar verder helpen, niet om slim doen.
De SEO die overleefde
De opdracht van de klant was helder: verlies geen posities. Een domein van 14 jaar oud met stabiel organisch verkeer is niet de plek om op launch day kennis te maken met Core Web Vitals. Drie dingen telden, in deze volgorde.
301's, geen 302's, en niet geketend
Elke redirect is één hop. De /redir/-handler stuurt direct met een 301 door naar de live URL. We sturen niet door naar een zoekresultatenpagina, we gebruiken geen 302 omdat "we het misschien nog aanpassen," en we ketenen niet door een www-naar-apex-rewrite die er nog een hop bovenop legt. We testten de hele top 5.000 met een script dat redirects volgt en hops telt; alles boven 1 is voor launch rechtgezet.
Canonical, schema en een soft-launch sitemap
De oude SEO Booster spuugde al zes jaar Product schema uit met redelijk correcte velden. We spiegelden dezelfde offers.price, offers.availability en aggregateRating output in de Woo-templates in plaats van het default thema iets afwijkends te laten schrijven. Dezelfde canonicals, dezelfde Open Graph-afmetingen. Het doel was dat elke productpagina er voor een crawler identiek uitzag, ook al was hij vanaf nul opgebouwd.
We hielden beide sites 36 uur lang bereikbaar op verschillende hostnames. De sitemap.xml van de nieuwe site ging eerst online. We vroegen via de URL-inspectie van Search Console een representatief product op als Googlebot, wachtten tot er "Geïndexeerd" stond en draaiden daarna de DNS om. Volledige herindexering duurde ongeveer elf dagen. De posities zakten op dag twee 3 à 4 plekken en zaten op dag veertien weer boven baseline.
Zes weekenden, op volgorde
Voor wie een vergelijkbare planning maakt: zo zagen die weekenden er in de praktijk uit.
- Weekend 1. Staging-kloon. Producten, categorieën en klanten geïmporteerd via WP All Import. Slugs vastgezet. Nog geen front-end thema.
- Weekend 2. Bestellingen geïmporteerd (15.400 historische orders in
wp_postsalsshop_order). Mollie en iDEAL opnieuw gekoppeld via een sandbox. Eén echte testorder van begin tot eind geplaatst. - Weekend 3. Thema opnieuw opgebouwd vanuit de HTML van het oude template, niet vanuit een gratis WooCommerce-thema. Dit was het langste weekend en degene waar je het meeste budget voor moet reserveren.
- Weekend 4. De URL-map, de
/redir/-handler, het.htaccess-blok, het verificatiescript. - Weekend 5. Specials-brug, btw-regels (14 EU-landen, volledige OSS-btw), verzendzones, transactionele e-mails.
- Weekend 6. Content freeze op de oude shop. Laatste delta-import van bestellingen en nieuwe klanten uit het freeze-venster. DNS-switch op zaterdag om 06:00 lokale tijd. Tot maandag 18:00 stand-by.
Er was ook een Weekend 7, buiten het budget, voor alles wat we hadden gemist. Er is altijd een Weekend 7.
Werken met de oude database terwijl de nieuwe live staat
Het stuk dat nooit in de planning zit, zijn de drie maanden daarna. Je hebt een legacy MySQL-database met de canonieke historische data, een nieuwe database waar nu naartoe geschreven wordt, en een finance-afdeling die nog altijd vragen stelt in het vocabulaire van het oude schema. En je hebt een .htaccess-blok dat blijft groeien zodra marketing oude campagne-URL's naar nieuwe landingspagina's doorstuurt.
We zetten de oude database op een read replica en gaven de boekhouder een kleine query-console met vijf opgeslagen queries. Elk ander "kun je X van voor de migratie even opzoeken"-verzoek liep via die console. Het scheelde zo'n halve dag per week en haalde de verleiding weg om losse tabellen terug te syncen naar het live systeem, het soort beslissing dat een jaar later een zondagavond-incident wordt.
Toen we Pier bouwden kwamen we deze exacte vorm bij genoeg klantprojecten tegen om er de kernloop van te maken: dock de FTP-root van de live host en de MySQL editor in hetzelfde venster, draai je opgeslagen queries naast de .htaccess die je aan het bewerken bent, en houd op elke wijziging version history bij zodat een verkeerde redirect met één klik terug te draaien is. Het vervangt je editor niet; het houdt de legacy site en de nieuwe in hetzelfde beeld terwijl je de lange staart van opruimwerk afmaakt.
Het kleinste wat je vandaag kunt doen: exporteer de organische landingspagina's van de afgelopen twaalf maanden uit Search Console, plak ze naast je nieuwe sitemap en sorteer. De gaten zijn het project.
— Vragen —
Waarom WooCommerce in plaats van Shopify voor een osCommerce-migratie?
Catalogus onder ~5k SKU's, een team dat al gewend is aan WordPress, en de noodzaak om server-side controle te houden over redirects en aanpassingen op PHP-niveau. Shopify werkt prima; het dwingt alleen een ander operating model af.
Hoe lang moet de oude osCommerce-site bereikbaar blijven na cutover?
36 tot 72 uur op een andere hostname is meestal genoeg om sitemaps, schema-pariteit en een representatieve crawl te verifiëren. Houd de database zelf minstens drie maanden als read replica beschikbaar.
Wat is het grootste risico bij het mappen van oude product-URL's?
Hergebruikte legacy-IDs. Oude admins recyclen products_id-waarden zonder iets te zeggen, waardoor een 301 op een echte 200-pagina kan landen die het verkeerde product toont. Crawl na launch altijd de top organische URL's.