Legacy sites
Een custom PHP-CRM vervangen: Next.js strangler-playbook
De recruiters hadden het opgegeven met het CRM en draaiden actieve plaatsingen vanuit een Google Sheet. Zo vervingen we de PHP-stack eronder zonder freeze-week.

Het CRM op het Tilburgse kantoor was in 2018 geschreven door een freelancer die in 2020 naar Berlijn vertrok. Tegen de tijd dat wij binnenkwamen, draaiden de recruiters hun actieve plaatsingen vanuit een gedeelde Google Sheet, omdat het kandidatenfilter in de PHP-app twee maanden eerder gestopt was met resultaten teruggeven en niemand had uitgevogeld waarom. De operationeel directeur deed niet meer alsof. Het CRM stond er nog. Niemand gebruikte het voor iets dat ertoe deed.
Dit is het playbook om het te vervangen.
Wat we in de doos vonden
Voordat we een vervanger plannen, sluiten we ons aan op de patiënt. Twee weken lees-toegang, een staging-kopie van de database, en een notitieblok. Geen toezeggingen aan de klant, behalve een schriftelijke beoordeling aan het eind.
De stack waar we binnenliepen:
- PHP 7.2 op één Hetzner-VPS. End of life sinds november 2020.
- MySQL 5.7 met zo'n 80.000 kandidaatrecords, 230.000 sollicitatieregels, en 11 miljoen regels logruis.
- Een custom MVC-framework met route-definities verspreid over drie bestanden.
- Een jQuery-bestand van 4.800 regels in
/public/assets/js/main.js. - File uploads die rechtstreeks naar
/var/www/uploadswerden geschreven, wat toevallig ook de plek was waar de nachtelijke database-back-up werd gedumpt. - Elf cron jobs. Zes gooiden al negen maanden lang stilletjes errors. Drie waren onopgemerkt onmisbaar.
Eén persoon kende het schema nog: een senior recruiter genaamd Sebas die in 2019 twee weken met de freelancer had gepaird. Hij tekende ons een kaart op een servet. Het servet bleek nauwkeuriger dan de database-documentatie.
Waarom we het niet vanaf nul hebben herschreven
Het plan om "in een grot te herschrijven" verleidt elk team bij elk legacy-project. Een schone Next.js-app, een fris Postgres-schema, half zoveel regels code, moderne conventies. Twaalf weken ontwerpvrijheid, een weekend cutover, klaar.
We hebben het niet gedaan. Het CRM heeft 47 unieke schermen. Recruiters gebruiken er 12 dagelijks, acht wekelijks, en de overige 27 zijn zeldzaam maar onmisbaar: compliance-rapporten, het jaaroverzicht van plaatsingen, de export voor de accountant. Alle 47 herbouwen in een grot betekent drie maanden zonder zichtbare voortgang, dan een weekend cutover, dan een paniekweek, en daarna een kwartaal bug-triage met een team dat het vertrouwen in het nieuwe systeem al kwijt is.
Dus kozen we voor het strangler fig-patroon en spraken we af om elke vrijdag iets bruikbaars op te leveren.
De strangler-vorm, in drie stukken
Drie componenten stonden eind week één klaar:
- Een Next.js 15-app, gedeployed op
app.[klant].nl, achter dezelfde OIDC-provider als de PHP-app. We zetten Authentik voor beide, zodat een recruiter één keer inlogt en landt waar zijn sessie naartoe moet. - Een Caddy reverse proxy die specifieke paden naar Next.js routeert zodra dat scherm live staat, en al het andere naar de oude PHP-app. Een route toevoegen is één Caddy-reload.
- Een dunne Postgres-database naast MySQL, gesynchroniseerd door Debezium, dat de binlog van MySQL streamt naar een kleine consumer die naar Postgres schrijft.
De Caddy-config is saaier dan het klinkt:
app.client.nl {
@next path /candidates* /vacancies* /inbox*
reverse_proxy @next next:3000
reverse_proxy php:80
}
Die config groeide over tien weken. Tegen week negen had hij acht path-matchers en werd de PHP-fallback bijna nooit meer geraakt.
Eerst reads, dan writes, nooit allebei tegelijk
Het lastige aan elke strangler is de cutover van writes. Reads zijn makkelijk. Je kunt vanuit beide kanten lezen, vergelijken, doorgaan. Writes zijn waar je ontdekt wat je gemist hebt.
We haalden elke tabel door drie modes:
- Mode A. PHP schrijft naar MySQL. Debezium repliceert naar Postgres. Next.js leest alleen uit Postgres.
- Mode B. PHP en Next.js schrijven allebei naar MySQL. Debezium repliceert nog steeds. Next.js leest uit Postgres, PHP leest uit MySQL, de twee blijven convergent omdat niemand direct naar Postgres schrijft.
- Mode C. Next.js schrijft naar Postgres. Een omgekeerde stream duwt Postgres-wijzigingen terug naar MySQL, tot de PHP-schermen voor die tabel ontmanteld zijn.
De candidates-tabel ging in negen dagen door A en C. vacancies duurde drie weken vanwege een foreign key naar een freelance_status-tabel die niemand kon uitleggen. Bleek dat de freelancer midden in een refactor zat toen hij naar Berlijn vertrok. De refactor was nu van ons.
Voor de initiële historische sync gebruikten we pgloader, dat het gros in ongeveer veertig minuten afhandelde voor 11 miljoen regels. We hebben de log-tabel niet gemigreerd. Niemand had er sinds 2021 een query op gedaan, en hem meeslepen zou het nieuwe schema 60% groter hebben gemaakt voor data waarvan de recruiters niet wisten dat ze bestond.
Audit je cron jobs voordat je één scherm migreert. De vrijdagavond-cron "expire stale leads" in het oude CRM hield stilletjes het maandag-pipelineview gezond. We ontdekten dit pas in week zes, toen het nieuwe Next.js-pipelineview voor het eerst in drie jaar er klopte en een recruiter aannam dat het stuk was.
Een dunne agent-laag, geen dikke
De klant vroeg op dag één naar AI. Elk uitzendbureau in 2026 is al gepitched op een kandidaat-matchingmachine door iemand in een half-zip. De verleiding is om te over-engineeren.
We hebben vier agents in productie gezet, elk met minder dan 200 regels orkestratiecode. De vorm: een Postgres tools-tabel, een Postgres agent_runs-tabel, een Postgres-functie die de tools materialiseert die een agent kan aanroepen, en een kleine Next.js API-route die de loop draait.
- CV-intake. Neemt een PDF- of DOCX-upload, geeft gestructureerde kandidaatdata terug (naam, e-mail, huidige rol, laatste drie banen, skills) en schrijft een regel naar
candidate_drafts. Een recruiter bevestigt nog steeds voor de merge. We maken niet automatisch kandidaten aan. - Match. Neemt een
vacancy_id, geeft gerangschikte kandidaten terug met één zin reden per stuk. De redenen worden opgeslagen, zodat een shortlist een maand later geaudit kan worden als iemand vraagt hoe hij tot stand kwam. - Outreach-drafter. Neemt een kandidaat en een vacature, geeft een conceptmail in de stem van de recruiter terug. Elke recruiter heeft een kleine fine-tune set, gebouwd uit hun eigen laatste 600 verzonden berichten, gefilterd op de berichten die antwoord kregen.
- Inbox-triage. Trekt uit de gedeelde inbox, classificeert antwoorden (
interested,pass,out-of-office,question) en zet ze met een tag in het CRM. Geen auto-replies. Een mens beantwoordt ze nog steeds.
Totale agent-infrastructuur: ongeveer 1.800 regels TypeScript, één Postgres-schema, geen Kubernetes, geen agent-framework. We hebben bij eerdere builds op de harde manier geleerd dat zware abstracties meer kosten dan ze opleveren als de loop zo klein is.
Cutover, week voor week
De volledige tijdlijn, ingedikt:
- Week 1. SSO voor beide apps. Postgres staat. Debezium trekt.
- Week 2. Eerste Next.js-scherm: kandidatenzoek. Mode A. Recruiters zoeken in de nieuwe UI, bewerken in de oude.
- Week 3. Kandidaatdetail en CV-upload. Intake-agent live in read-only mode.
- Week 4. Vacaturescherm. Mode B op de candidates-tabel. Beide apps schrijven nu.
- Week 5. Match-agent live. Recruiters shortlisten in Next.js, bevestigen gesprekken in PHP.
- Week 6. Outreach-drafter live. De verzending loopt nog steeds via Mailgun via de PHP-cron. We hebben alleen veranderd wát die stuurde.
- Weken 7 en 8. De 27 zeldzame schermen. 19 hebben we herbouwd in Next.js. De andere 8 bleken helemaal geen UI nodig te hebben en werden Markdown-rapporten, gegenereerd op een Postgres-cron.
- Week 9. Mode C op candidates. PHP ging read-only voor die tabel.
- Week 10. Mode C op alles, behalve de accountant-export, die we tot het jaareinde in PHP lieten staan om het bestandsformaat niet midden in een kwartaal te veranderen.
- Week 12. De oude VPS uitgezet. We hebben een tarball bewaard.
Tien weken overlap, twee weken opruimen. Geen freeze-week. Geen zaterdagcutover. De dagelijkse praktijk van de recruiters bleef de hele tijd doordraaien.
Drie dingen die we anders zouden doen
Eerst de cron jobs auditen. De helft van de elf legacy-crons faalde stilletjes en drie hielden in stilte de pipelineview overeind. We hadden dit in week één moeten vangen, niet in week zes. grep -r "cron" /etc/cron.d /var/spool/cron op de oude VPS voordat je één regel Next.js schrijft.
Behandel de upload-directory als untrusted. /var/www/uploads bevatte CV's van 2018 tot 2025. We namen aan dat het allemaal PDF's waren. Ongeveer 4% was dat niet, waaronder een stapel .pages-bestanden, twee .exe-bestanden die over hun type logen, en een map met iemands vakantiefoto's die per ongeluk waren geüpload. De intake-agent had meningen. De OWASP file upload cheat sheet is het juiste startpunt voor de nieuwe pipeline.
Langer laten bezinken in Mode B. We haalden vacancies na vier dagen uit Mode B naar Mode C. Een bug in de Next.js-validatielaag schreef drie vacatures weg met NULL company_id en PHP slikte ze bij het inlezen. We vingen het binnen 48 uur, maar het was vermijdbaar. Twee weken in Mode B is ons nieuwe minimum voor elke tabel waar twee apps naar schrijven.
De diff die ertoe deed
Het CRM was niet het probleem. Het was het symptoom. De recruiters verbrandden elk zo'n vier uur per week aan taken die het CRM had moeten absorberen: CV-data opnieuw intypen, koude outreach opstellen, inkomende mail taggen, kandidaten-longlists vanaf nul bouwen als het zoekfilter het begaf.
Twintig recruiters. Vier uur per week. Vijftig werkweken. Vierduizend uur per jaar, teruggegeven aan de mensen wiens werk het is om daadwerkelijk kandidaten te plaatsen.
Toen we de agent-laag bouwden voor de Tilburgse klant, liepen we ertegenaan dat recruiters geen chatbot wilden. Ze wilden dat hun CRM stopte met tegen ze vechten. We hebben het opgelost door de UI human-driven te houden en de AI-agents op de saaie taken te zetten, niet op de creatieve. Dat is de vorm van de meeste bruikbare agents die we tegenwoordig leveren.
Eén klein ding dat je vandaag zou kunnen doen, als je een legacy-systeem hebt dat je al maanden van plan bent te vervangen: open de errorlog. Zoek de oudste unieke exception die vandaag nog steeds afgaat. Vraag waarom niemand hem heeft opgelost. Het antwoord is meestal de vorm van de rewrite die je vermijdt.
Kern
De strangler wint van de rewrite, omdat je op vrijdag één scherm kunt opleveren en op maandag aan de recruiters kunt vragen wat ze ervan vinden.
FAQ
Hoe lang duurt een strangler-migratie voor een CRM van dit formaat?
Tien weken parallel draaien plus twee weken opruimen, voor 47 schermen en ongeveer 80.000 kandidaten. De meeste tijd gaat zitten in het laten bezinken van Mode B en de lange staart van zeldzame schermen, niet in nieuwe code.
Waarom overstappen van MySQL naar Postgres en niet op MySQL blijven?
We wilden JSONB voor de agent_runs-tabel, logical-replication-primitieven, en row-level security voor later multi-tenant werk. MySQL had ook gewerkt, maar dan hadden we die stukken zelf moeten bouwen.
Hebben de AI-agents recruiters vervangen?
Nee. De agents namen het werk over dat recruiters vervelend vonden: CV's parsen, eerste mails opstellen, binnenkomende antwoorden taggen. Bevestiging, oordeel en de echte kandidaatrelaties blijven bij mensen.
Wat is de meest risicovolle stap van een strangler-migratie?
Een tabel verplaatsen van Mode B (dual write) naar Mode C (single write). Een validatiebug kan stilletjes verkeerde regels wegschrijven die de legacy-app als ontbrekende data inleest. Laat het minstens twee weken bezinken voor je flipt.