— Artikel — № 078

078 —Migration

600 PHP-pagina's naar WordPress migreren zonder linkbreuk

Een Nederlands bureau verhuisde 612 URL's custom PHP naar WordPress in vijf weken. Geen dode links. De crawl, de rewrites, de MySQL-port en wat alsnog brak.

Bovenaanzicht op linnen: papieren redirect-kaart, 301 spiekbriefje, manilla map, messing plaatje, rode lakzegel.
Hero · gestileerd stilleven№ 078

De briefing kwam binnen in een gedeeld Notion-document op een dinsdagmiddag. Een bureau van 22 mensen waar we mee werken had de verouderde site van een meubelfabrikant overgenomen. 612 URL's custom PHP uit 2014, draaiend op PHP 7.4, gehost op één Strato VPS, met een codebase uit het mysql_real_escape_string()-tijdperk die de deprecation cliff van PHP 8.2 niet zou overleven. De klant wilde WordPress. Het non-negotiable: er mocht geen enkele inkomende link breken.

Dat klinkt simpel tot je naar de linkgrafiek kijkt. Twaalf jaar aan organische backlinks. Vakbladen die naar productpagina's linken op SKU. Vergelijkingssites van retailers die diep linken naar categoriefilters. Veertien PDF-datasheets op /pdf/datasheet-*.pdf, waarvan er twee opdoken in branche-rapporten die het salesteam van de fabrikant elk kwartaal nog naar inkoopcontacten mailde. Een 404 op een van die URL's zou niet alleen de SEO raken. Die zou als klacht in iemands inbox landen.

Hieronder de aanpak die het bureau heeft gevolgd. Vijf weken, nul kapotte URL's bij cutover, en een handvol fouten die het opschrijven waard zijn. De hele klus is een tekstboek-PHP-naar-WordPress-migratie, maar de waarde zit in de kleine beslissingen die vóór de eerste WordPress-plugin werden genomen.

Elke URL in kaart brengen voordat je iets nieuws bouwt

De eerste klus is niet bouwen. Het is precies weten wat je hebt. Het CMS vertelde ons dat er 612 gepubliceerde pagina's waren. De realiteit, nadat we twaalf maanden aan Apache access logs hadden opgehaald, lag dichter bij 700 URL's die echt menselijk verkeer kregen.

We begonnen met een recursieve crawl en kruisten die met de logs:

wget --spider --recursive --no-parent \
  --domains=furniture-example.com \
  -o crawl.log https://furniture-example.com

# Then pull every URL ever hit in the last 12 months
awk '{print $7}' /var/log/apache2/access.log* \
  | sort | uniq -c | sort -rn > url-canon.txt

De crawl gaf 612 terug. De logs leverden er 47 extra op. De meeste van die 47 waren oude campagne-landingspagina's die het CMS stilletjes had verweesd, maar die Google nog wel indexeerde. Acht waren querystring-varianten die echte semantische lading droegen, zoals /products.php?cat=12&item=144, die een brochureachtige PHP-site uit 2017 als echte product-URL had behandeld.

We dumpten de union in een spreadsheet met drie kolommen: URL, hits over 12 maanden, intent. De intent-kolom was het echte werk. Voor elke rij beslis je: voortleven met een nieuwe slug, redirecten naar de dichtstbijzijnde match, of stilletjes met pensioen. Die stap valt niet te automatiseren. De bureaulead besteedde er twee dagen aan. Die twee dagen scheelden ze drie weken verderop.

De nieuwe permalinkstructuur ontwerpen

WordPress staat standaard op /%postname%/ en daar zijn we ook geland. De oude site gebruikte overal PHP-bestandsextensies: /products/oak-dining-table.php, /about-us.php, /pdf/datasheet-oak.pdf. De .php er onderweg via Apache afhalen is de voor de hand liggende zet, maar voor de PDF's gold een andere beslissing.

De vakbladen hadden direct naar /pdf/datasheet-oak.pdf gelinkt. WordPress zou die bestanden van nature in /wp-content/uploads/2026/06/ willen hebben. Die verhuizing zou elke link in elk artikel vanaf 2017 ongeldig hebben gemaakt. Dus we hielden het legacy-pad aan. De PDF's staan op schijf in /wp-content/uploads/legacy/datasheet-oak.pdf, en een Apache-rewrite vangt het oude pad af en serveert ze ter plekke. Geen 301, geen client-side blink, gewoon een transparante map.

Van de 659 URL's op de canon-lijst zag de verdeling er zo uit:

  • 547 werden WordPress-posts of custom post types met een één-op-één-slug
  • 51 werden samengevoegd tot een kleinere set nieuwe pagina's, met de oude URL's die er via 301 binnenkwamen
  • 14 waren de PDF's, behouden op het oorspronkelijke pad
  • 47 zombie-URL's werden ge-301'd naar het dichtstbijzijnde levende equivalent

De .htaccess-laag die alles bij elkaar houdt

De volledige redirect map staat in één enkele .htaccess op de document root. Volgorde doet ertoe. Legacy-regels moeten vóór het WordPress-blok komen, anders slokt de catch-all van WordPress alles op. De Apache mod_rewrite-documentatie is de canonieke referentie, maar de vorm is voorspelbaar.

# Legacy redirects. MUST come before the WordPress block.
RewriteEngine On

# Query-string product URLs: /products.php?cat=12&item=144
RewriteCond %{QUERY_STRING} ^cat=12&item=144$
RewriteRule ^products\.php$ /products/oak-dining-table/? [R=301,L]

RewriteCond %{QUERY_STRING} ^cat=12&item=145$
RewriteRule ^products\.php$ /products/oak-bench/? [R=301,L]

# Legacy PDFs: served in place from a frozen directory
RewriteRule ^pdf/datasheet-(.+)\.pdf$ \
  /wp-content/uploads/legacy/datasheet-$1.pdf [L]

# Specific .php pages
RewriteRule ^about-us\.php$    /about/      [R=301,L]
RewriteRule ^contact\.php$     /contact/    [R=301,L]

# Generic .php fall-through
RewriteRule ^(.+)\.php$ /$1/ [R=301,L]

# WordPress
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]

De 51 individuele product-redirects stonden in een gegenereerd blok van het bestand, geschreven door een klein Node-script dat de redirect-kolom van de spreadsheet inlas. Het is het soort bestand dat je op schaal niet handmatig bewerkt. We versioneerden de generator, niet de output. Toen een stakeholder twee weken na cutover een redirect-doel wilde wijzigen, was de aanpassing één rij in de spreadsheet en één keer regenereren.

Content overzetten uit de custom MySQL-tabellen

De oude site bewaarde alles in drie tabellen: products, categories, news. De bodies waren HTML met inline styles, een decennium aan opgestapelde markup. Die in wp_posts trekken vergde zorg, niet omdat de import technisch lastig was, maar omdat de import het moment is om de rommel weg te gooien.

We gebruikten wp-load.php zodat WordPress' eigen hooks afgingen tijdens insert. Term-toewijzingen, slug-uniciteit, ACF-veldsaves, het werkt allemaal als je via wp_insert_post() gaat in plaats van directe INSERT-statements te schrijven. De WordPress-referentie voor wp_insert_post beschrijft het contract.

<?php
require_once '/var/www/wordpress/wp-load.php';

$old = new mysqli($host, $user, $pass, 'legacy_furniture');
$old->set_charset('utf8mb4');

$result = $old->query(
  "SELECT id, name, slug, body, category_id, created_at
   FROM products
   ORDER BY id"
);

while ($row = $result->fetch_assoc()) {
    $clean_body = sanitize_legacy_html($row['body']);

    $post_id = wp_insert_post([
        'post_title'   => $row['name'],
        'post_name'    => $row['slug'],
        'post_content' => $clean_body,
        'post_status'  => 'publish',
        'post_type'    => 'product',
        'post_date'    => $row['created_at'],
        'meta_input'   => [
            'legacy_id'          => $row['id'],
            'legacy_category_id' => $row['category_id'],
        ],
    ]);

    if (is_wp_error($post_id)) {
        fwrite(STDERR, "FAIL {$row['id']}: "
            . $post_id->get_error_message() . "\n");
        continue;
    }
    echo "imported {$row['id']} -> {$post_id}\n";
}

Drie details die het waard zijn er even uit te lichten. Het post_date-veld krijgt de oorspronkelijke timestamp, waardoor de jaararchieven blijven kloppen. De legacy_id-meta laat je later redirects rijmen zonder te gokken. En sanitize_legacy_html() was een functie van 60 regels die inline styles stripte, image-paden van /img/products/ naar de nieuwe media library normaliseerde, en de body door HTML Tidy haalde. De OWASP-richtlijn over cross-site scripting is het herlezen waard voordat je een decennium aan untrusted HTML in een nieuwe database kopieert.

De rewrite map testen tegen de canon

Voordat hier iets in de buurt van productie kwam, moesten we weten of de redirect map daadwerkelijk deed wat de spreadsheet beweerde. We bouwden een staging-kopie van de nieuwe WordPress-installatie op een subdomein, zetten daar de voorgestelde .htaccess in, en draaiden een kleine loop die elke URL uit de canon raakte en het resultaat noteerde.

while IFS= read -r url; do
  status=$(curl -s -o /dev/null \
    -w "%{http_code} %{redirect_url}" \
    "https://staging.furniture-example.com${url}")
  echo "${url} -> ${status}"
done < url-canon.txt > redirect-results.txt

Het resultaatbestand gaf ons de waarheid. Elke 404 was een ontbrekende regel. Elke 200 op een oude URL betekende dat een rewrite die een 301 had moeten zijn werd opgeslokt door WordPress. Elke 301 die wees naar een URL die niet in de nieuwe sitemap stond, betekende dat we de bestemming verkeerd hadden getypt. We draaiden deze loop zeven keer over drie dagen. De zevende ronde kwam terug met nul 404's, nul onverwachte 200's, en 612 schone 301's.

Cutover en de dingen die alsnog braken

DNS schakelde op een woensdag om 06:00 lokale tijd. De bureaulead zat met een terminal open op de Apache-log van de nieuwe server, en een tweede venster dat de oude server tailde, die we 72 uur warm hielden achter een andere hostname voor het geval rollback nodig zou zijn. Dat was niet nodig.

Binnen vier uur kwamen in de nieuwe log 32 unieke 404's naar boven die de URL-canon had gemist. Ze vielen in drie groepen.

Een vendor-emailtemplate, die nog steeds maandelijks naar groothandelsklanten werd verstuurd, hotlinkte /img/hero-2018.jpg. We dropten een kopie van het beeld op het oude pad en gingen verder. Drie retailerportalen polden /api/products.php?format=json voor voorraad. Het bureau schreef een PHP-shim van 40 regels die op het legacy-pad leefde en dezelfde vorm terugkaatste, met een WP REST-query eronder. Twee PDF's waarvan we dachten dat ze met pensioen waren, bleken gelinkt vanuit een industrie-whitepaper die Google in 2019 had geïndexeerd. We zetten ze binnen een uur terug op het legacy-pad.

Het coverage-rapport in Search Console wiebelde acht dagen, daarna stabiliseerde het. Op dag elf zat het aantal geïndexeerde pagina's binnen drie van het pre-migratiegetal, en doken de nieuwe URL's op in queries waarop de oude slugs eerder hadden gerankt. Dat deel is grotendeels Google die bijhaalt. Versnellen kun je niet kopen.

Wat we hiervan voor ons eigen werk hebben meegenomen

Toen we Pier bouwden liepen we precies dit patroon meer dan eens tegen het lijf: een klant wil de URL-structuur van een verouderde site refactoren maar kan zich geen enkele dode link veroorloven. De manier waarop we het uiteindelijk hebben opgelost: elke .htaccess-aanpassing terugdraaibaar maken vanuit de version history, en de MySQL editor een manier geven om een redirect map te testen tegen een lijst geplakte URL's voordat er iets live gaat.

De kleinste versie hiervan die je vandaag kunt doen

Als er ergens op je roadmap een PHP-naar-WordPress-migratie staat, doe dan vanmiddag één ding. Trek de access logs van de afgelopen twaalf maanden uit je huidige hosting en draai dit:

awk '{print $7}' access.log* \
  | sort | uniq -c | sort -rn | head -200

De lijst die terugkomt is de URL-canon die je moet behouden. De rest is decoratie.

— Vragen —

Hoe lang duurt een migratie van 600 pagina's custom PHP naar WordPress?

Vijf weken voor het team hierboven beschreven. Twee daarvan gingen op aan URL-mapping en het opschonen van content voordat er een nieuwe WordPress-installatie werd aangeraakt. Sla die fase over en je betaalt er bij cutover voor.

Moet ik de oude .php-URL's voor altijd in leven houden?

Ja, zolang inkomende links er nog naar wijzen. Een 301 van het oude pad naar het nieuwe is permanent in die zin dat crawlers, bookmarks en emailtemplates die nog jaren zullen volgen.

Moet ik een redirect-plugin gebruiken in plaats van .htaccess?

Plugins zijn handig voor doorlopend onderhoud. De initiële migratie hoort in .htaccess, omdat die draait voordat WordPress opstart, sneller is, en het deactiveren van plugins of een themewissel overleeft.