← Blog

Legacy sites

Joomla 1.5 naar Laravel 12: playbook bij een douaneagent

Een 19 jaar oude Joomla 1.5 site, een eigen MySQL-CRM met 187.000 historische dossiers en een douanedeadline die niet wijkt. Zo verliep de migratie in de praktijk.

Jacob Molkenboer· Oprichter · A Brand New Company· 10 jun 2026· 9 min
Leren logboek, koperen sleutel op crème kaart met groen lint, ijzeren label op ivoorpapier, zijlicht.

De telefoon gaat op vrijdag om 16:42. Een container diepvriestonijn ligt bij ECT Delta en de AGS-aangifte moet vóór sluitingstijd binnen zijn. De dossierbehandelaar typt de EORI in de zoekbalk van het CRM. De pagina blijft hangen. Ze ververst de pagina. Hij blijft weer hangen. Ze opent phpMyAdmin in een tweede tabblad en draait de SELECT zelf.

Dit is de werkelijkheid bij zo'n dertig Nederlandse en Belgische douaneagenten met wie we de afgelopen twee jaar spraken. De site is een Joomla 1.5-installatie uit 2008. Het CRM is een eigen module, ooit gebouwd door een ontwikkelaar die intussen naar Spanje is geëmigreerd. De dossiertabel is MyISAM, 187.000 rijen, latin1_swedish_ci, en één FULLTEXT-index waarvan niemand weet hoe je hem opnieuw bouwt. Alles werkt, totdat het niet meer werkt.

Dit verhaal is het draaiboek dat we bij een van die agenten hebben gevolgd. Een Rotterdams bedrijf met 31 medewerkers, twee vestigingen, zo'n veertien miljoen euro aan jaarlijkse douaneomzet. De opdracht was klein in omvang en groot in gevoeligheid: vervang de site en het CRM, hou elk historisch dossier doorzoekbaar, verstoor het vrijdagmiddagritme niet. Het project liep vier maanden, met een parallelle periode en één cutover van vier uur.

Waarom Joomla 1.5 negentien jaar bleef draaien

Joomla 1.5 bereikte het einde van de algemene support in september 2012. Het Joomla-project kondigde dat ruim tien jaar geleden duidelijk aan. De agent die de site draaide, bleef er niet bij omdat ze het niet wisten. Ze bleven omdat de dossiertabel dertien jaar prijsgeschiedenis bevatte, AGS-referentienummers, kredietlimieten van klanten, en aantekeningen van medewerkers die al met pensioen waren. De site was een database met een UI eroverheen.

De site zelf had drie schermen die waarde leverden: een publieke marketingvoorkant, een klantlogin en een intern CRM waar het personeel feitelijk in leefde. Niemand vond het erg dat de marketingvoorkant op 2009 leek. Iedereen haatte het CRM, en niemand durfde het aan te raken.

Dat is het patroon. Oude PHP blijft draaien in sectoren waar data de software overleeft, waar het audittraject wettelijk geregeld is (douane, zorg, accountancy), en waar de kosten van een mislukte migratie een boete zijn in plaats van een terugbetaling. Wil je het vervangen, dan ligt de bewijslast bij jou, niet bij het platform.

Het dossiermodel mappen vóór het schema

We hebben twee weken lang geen code-editor opengezet.

De eerste taak in zo'n migratie is het tekenen van het entiteitenmodel dat de business daadwerkelijk gebruikt, niet het model dat in het oude schema zit. In het douanewerk zijn dat: dossier (één aangifte), partij (importeur, exporteur, agent), goederenregel (met HS-code), document (BL, factuur, paklijst, T1, EUR.1) en gebeurtenis (statuswijzigingen van aankomst tot vrijgave). Het Joomla-schema had tien tabellen. De business gebruikte zes entiteiten. De mapping was niet één-op-één. Eén van de oude tabellen was een join-tabel die in zeven jaar niemand had gevuld. Een andere was een duplicaat van de klantentabel uit een import uit 2014 die iedereen vergeten was.

We deden dit door drie dagen op kantoor te zitten. We keken waarop de dossierbehandelaars feitelijk zochten: een EORI-nummer, een containernummer, een klantnaam met een typefout, een datumbereik, een scheepsnaam. Elke zoekopdracht ging in de nieuwe zoekindex. Alles wat we nooit gebruikt zagen worden, kreeg een vlag voor cold storage in plaats van de hoofdindex. De klant was opgelucht toen we vertelden welke tabellen ze konden laten staan.

De database hervormen onder belasting

De oude database was MyISAM met latin1_swedish_ci. De nieuwe is InnoDB met utf8mb4_0900_ai_ci. Dat is niet een vinkje verzetten. Latin1 slaat hogebitkarakters op als single bytes die geen geldige UTF-8 zijn. Een directe dump-en-import levert mojibake op bij elke Nederlandse, Belgische, Franse en Duitse klantnaam in de tabel.

De conversie die we gebruikten:

mysqldump --default-character-set=latin1 \
  --skip-set-charset --hex-blob \
  legacy_db > raw.sql

# rewrite charset declarations in the dump
perl -pi -e 's/latin1_swedish_ci/utf8mb4_0900_ai_ci/g; \
             s/CHARSET=latin1/CHARSET=utf8mb4/g' raw.sql

mysql --default-character-set=utf8mb4 new_db < raw.sql

De truc is dat de bytes in de dump al geldige latin1 zijn. Dumpen met --default-character-set=latin1 --skip-set-charset zegt tegen MySQL dat hij de rauwe bytes moet teruggeven zonder transcoderen. Importeren in utf8mb4 interpreteert ze vervolgens correct. Controleer dit door klantnamen te selecteren met ë, é, ç en ß. Die moeten er goed uitzien in de nieuwe database als je query draait met --default-character-set=utf8mb4.

Let op

Als je latin1-tabel al mojibake bevat van een eerdere mislukte migratie, dan dubbelt deze truc de encoding en zit je met rommel die is opgeslagen als rommel. Draai een SELECT op een handvol rijen met accenttekens voordat je doorgaat met de volledige import. We hebben één project gezien waar deze stap was overgeslagen, en het team had het pas acht weken later door, nadat twaalfduizend facturen met verkeerde klantnamen de deur uit waren.

Zoeken in 187.000 dossiers

Een MyISAM FULLTEXT-index is niet draagbaar naar InnoDB zonder rebuild, en de tokenizer van InnoDB is slecht in exact-match zoeken op containernummers, EORI-strings en HS-codes. Hij splitst op leestekens en zet alles in kleine letters. Prima voor proza, nutteloos voor codes.

We hebben Meilisearch ervoor gezet. 187.000 dossiers passen in 240 MB op schijf. Een koude indexbuild draait in zes minuten. De Laravel-kant gebruikt Scout met de Meilisearch-driver:

// app/Models/Dossier.php
use Laravel\Scout\Searchable;

class Dossier extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id'            => (int) $this->id,
            'reference'     => $this->reference,
            'eori'          => $this->party_importer_eori,
            'container_nos' => $this->containers->pluck('number')->all(),
            'customer_name' => $this->customer?->name,
            'opened_at'     => $this->opened_at?->timestamp,
            'status'        => $this->status,
        ];
    }
}

De reden dat we opened_at als Unix-timestamp meegeven en niet als datumstring, is dat Meilisearch er dan native op kan filteren en sorteren. De reden dat we container_nos in het document plat slaan in plaats van bij elke zoekopdracht te joinen, is dat een behandelaar die TGHU2851004 typt één indexlookup raakt in plaats van drie round trips door Eloquent.

Resultaat: behandelaar typt zes karakters, drukt enter, ziet resultaten in 40 tot 90 milliseconden op een Hetzner-box van twaalf euro per maand. De oude Joomla-zoekopdracht deed er tussen de twee en elf seconden over, afhankelijk van hoeveel gebruikers op het systeem zaten.

EORI-lookups onder 400ms

Een EORI is de importeur- en exporteursidentificatie van de EU. De validatiereferentie is de EORI-validatieservice van de EU, beschikbaar als webformulier én als SOAP-endpoint. SOAP vanuit een Nederlands datacenter naar het Brusselse endpoint zit zelden onder de 300 milliseconden op een goede dag, en de dienst is uren achter elkaar traag of onbereikbaar geweest.

Dus we roepen hem nooit aan op de hot path.

De Laravel-app houdt een lokale tabel bij van elke EORI die hij ooit heeft gezien, inclusief het validatieresultaat en een refresh-timestamp. Een scheduled job hervalideert elke EORI in een rollend venster: zeven dagen voor actieve klanten, negentig dagen voor slapende. De lookup van de behandelaar raakt de lokale database, niet Brussel. Cold-path-validatie voor een EORI die we nog niet kennen, gaat in de queue en staat in de UI op 'checken', met een banner zodra het antwoord binnen is.

// app/Services/EoriLookup.php
public function lookup(string $eori): EoriResult
{
    $cached = EoriRecord::find($eori);

    if ($cached && $cached->fresh_enough()) {
        return EoriResult::fromCache($cached);
    }

    if (! $cached) {
        EoriRecord::create([
            'eori'      => $eori,
            'status'    => 'pending',
            'queued_at' => now(),
        ]);
        ValidateEoriJob::dispatch($eori);
    }

    return EoriResult::pending();
}

De mediane lookup-latency op het gecachte pad mat 38 milliseconden in productie. Het budget van 400 milliseconden dat de klant ons gaf is ruim, met marge voor de klantpagina die op drie EORIs tegelijk joint.

De email-agent-laag

Het CRM was de grootste helft van het project. De email-laag was de goedkopere helft die zich in drie weken terugbetaalde.

De inbox van een douaneagent bestaat grotendeels uit dezelfde vijf mails: een Bill of Lading van de vervoerder, een commerciële factuur van de verzender, een paklijst, een vrijgaveverzoek van de klant, en een vraag over ETA. Elk daarvan moet aan een dossier gekoppeld worden en de bijlage moet gearchiveerd.

De email-agent draait op een eigen mailbox waar alle vervoerders en klanten in BCC zitten. Hij doet drie dingen en stopt:

  1. Match het binnenkomende bericht aan een dossier, op BL-nummer, containernummer, of de In-Reply-To-header van de antwoordketen.
  2. Classificeer de bijlage en archiveer hem op het dossier onder het juiste documenttype.
  3. Bevat het bericht een vraag die de agent vanuit het dossier kan beantwoorden (een klant die vraagt wanneer een container bij ECT aankomt), stel dan een antwoord op en zet het in een review-queue. Nooit automatisch versturen.

De regel 'nooit automatisch versturen' is niet technisch. Hij is contractueel. Een douaneagent die per ongeluk een verkeerde ETA naar een klant stuurt, is aansprakelijk voor de demurrage. We hebben nog geen agent ontmoet die hierover met zijn beroepsaansprakelijkheidsverzekeraar wil discussiëren.

Dit is ook waar we het eens zijn met de recente herinnering dat elke CEO die denkt dat AI de medewerker vervangt, de rekensom verkeerd maakt. De dossierbehandelaar die 's ochtends veertig minuten kwijt was aan het sorteren van bijlagen, is daar nu zes minuten mee bezig. Die veertig minuten werden geen besparing op personeelskosten. Het werd tijd die ze besteedt aan de gesprekken die alleen zij kan voeren: de klant met een kredietprobleem, de container met beschadigde verzegelingen, het afgewezen EUR.1-certificaat.

De freeze, de cutover, de rollback

Het zwaarste deel van zo'n migratie is niet technisch.

We hebben het oude en het nieuwe systeem twee weken parallel laten draaien. Writes gingen naar beide. Reads gingen naar het oude. De dossierbehandelaars kregen te horen dat ze de nieuwe UI in week één moesten negeren, en in week twee als read-only tweede scherm moesten gebruiken. Aan het einde van week twee waren drie van de vijf behandelaars zonder dat we erom vroegen overgestapt op de nieuwe UI als hoofdscherm. Dat was het signaal waar we op wachtten.

De cutover-window was een zondagochtend om 06:00, vier uur, geen havenverkeer. De laatste delta-sync draaide van 05:00 tot 05:40. DNS schakelde om 06:00. Het rollback-plan was een gedocumenteerd dig-commando, een gedocumenteerde git revert, en een geprinte kopie van het root-wachtwoord van de oude database in de kantoorkluis. We hebben het nooit nodig gehad. De maandagochtendmeeting was, in de woorden van de operations manager, 'saai, en dat is waar we voor betaald hebben'.

De oude Joomla 1.5-site bleef nog zes maanden online op legacy.[klantdomein].nl als read-only archief, en ging daarna eraf. Niemand merkte het.

Wat we anders zouden doen

Twee dingen, beide over scope.

Ten eerste de marketingvoorkant. We hebben hem opnieuw gebouwd binnen dezelfde Laravel-app, in de aanname dat de klant zelf copy zou willen aanpassen. Dat wilden ze niet. Ze vroegen ons om copy-aanpassingen mee te nemen in een jaarlijks retainer. We hadden de publieke site nog zes maanden op de oude hosting kunnen laten staan en twee weken werk besparen, en de klanten van onze klant hadden het verschil niet gemerkt.

Ten tweede de documentopslag. We hebben historische pdf's tijdens de cutover van lokale schijf naar S3 verhuisd. Achteraf hadden we ze de eerste maand op lokale schijf moeten laten staan en in een tweede pass moeten migreren. Door ze in de cutover te bundelen kwam er twee uur bij de freeze-window, terwijl de business er op dag één niets voor terug kreeg.

Toen wij de email-agent voor deze Rotterdamse agent bouwden, liepen we ertegenaan dat carrier-mails in zeventien verschillende layouts binnenkomen en het BL-nummer zelden twee keer op dezelfde plek staat. We hebben dat opgelost door de classifier te trainen op drie jaar eigen archiefmail van de agent, in plaats van op een generiek shipping-corpus. Dat is het soort oordeel waar een legacy-migratie bijna altijd op leunt.

Draai je iets dat is begonnen als Joomla 1.5, ezPublish of handgeschreven PHP 5, dan zijn de goedkoopste vijf minuten die je vandaag kunt besteden: dump je tien meestgebruikte zoekopdrachten uit het access log, tel de unieke klantcodes of EORIs die ze raken, en vraag je af of die queries in het nieuwe systeem overeind blijven. De kaart van wat de business daadwerkelijk doet staat in die lijst, en het migratieplan schrijft zichzelf eromheen.

Kern

De eerste oplevering van een legacy-migratie is een lijst van de queries die de business daadwerkelijk draait, niet een lijst van tabellen in de oude database.

FAQ

Waarom niet op Joomla blijven en gewoon door de major versies upgraden?

Het upgrade-pad van Joomla 1.5 naar 5.x is niet aaneengesloten. De eigen CRM-module had geen maintainer en draaide op Mootools, een JavaScript-library die sinds 2010 niet meer onderhouden wordt. Een herbouw was goedkoper dan een port.

Waarom Laravel 12 en niet Symfony of een ander framework?

Het andere team bij de klant gebruikte al Laravel voor een kleiner intern tool. Vertrouwde stack wint van theoretisch optimale stack. De queue, scheduler en Scout-integratie van Laravel 12 dekten elk bewegend deel van het project standaard af.

Hoe lang duurde de migratie van begin tot eind?

Vier maanden van kickoff tot cutover, met een team van 31 mensen dat al die tijd doorwerkte. Daarvan was ongeveer zes weken discovery en validatie tijdens de parallelle periode, geen code.

Waarom Meilisearch in plaats van Postgres full-text search?

Postgres full-text is goed in proza. Hij is middelmatig in exact-match zoeken op identifiers (EORI, container, HS-codes), wat dossierbehandelaars de hele dag doen. Meilisearch handelt beide vormen goed af.

legacy sitesmigrationjoomlaphpmysqlarchitecture

Iets bouwen?

Start een project