← Blog

PHP

PHP 7.4 interne tool met pensioen: wrap, niet herschrijven

Een 12 jaar oude PHP 7.4 tool draait de backoffice. Niemand durft hem te herschrijven. Dit is hoe we de UI uitfaseren zonder de motor aan te raken.

Jacob Molkenboer· Oprichter · A Brand New Company· 8 jun 2026· 9 min
Versleten linnen handboek met messing sleutel, calqueerpapier, groen lintje en rood lakzegel op ivoorkleurig vlak.

De vraag van €40k die niemand wil beantwoorden

De operations lead bij een distributeur van €9M in Eindhoven zit op dag twee van het doorklikken van 412 factuurregels in een PHP 7.4 adminpaneel uit 2014. Ze heeft nog een halve dag voor de maand sluit. De tool werkt. Niemand durft eraan te komen. De originele developer is in 2017 vertrokken, de codebase bestaat uit acht modules en 142 schermen ongeonderhouden PHP, en de offerte voor een rewrite die in de inbox van de CFO ligt komt op €40k tot €60k voor vier tot zes maanden werk.

Toen wij in september aanschoven, was de briefing de gebruikelijke: "we moeten dit herschrijven op een moderne stack". De CFO zei nee tegen de offerte. Wij waren het met haar eens.

Acht maanden later gebruikt de operations lead de originele UI misschien vijf uur per week. De andere vijfendertig uur praat ze met een agent die dezelfde backend aanstuurt via een typed API die wij ervoor hebben gebouwd. De PHP 7.4 code is ongewijzigd. Het MySQL-schema is ongewijzigd. De rewrite is nog steeds niet gebeurd. Het argument ervoor wordt elke maand zwakker.

Waarom een rewrite de verkeerde eerste zet is

De reflex om een verouderde interne tool te herschrijven is bijna altijd verkeerd, en de reden zit in het woord "intern". Systemen voor klanten worden herschreven omdat de klantbeleving het product is. Interne tools worden herschreven omdat iemand in het management las over technical debt en zenuwachtig werd.

De eerste zes maanden van elke rewrite leveren een slechtere tool op. Workflows die de originele developer in vijf jaar kleine bugfixes had ingebouwd verdwijnen op dag één en komen de volgende achttien maanden terug als supporttickets. Het team dat de oude tool op routine gebruikte heeft nu training nodig.

De institutionele kennis in de PHP-code gaat verloren in de vertaling. Elke interne app van deze leeftijd heeft minstens één scherm waar één knop zeven dingen doet, en minstens drie van die dingen zijn cruciaal voor een proces dat niemand heeft gedocumenteerd. Een rewrite implementeert ofwel alle zeven opnieuw (en erft dezelfde rommel) of pakt er vier en breekt de andere drie.

En de PHP 7.4 motor werkt nog. PHP 7.4 krijgt sinds november 2022 geen securitypatches meer, maar een tool die achter een firewall staat en alleen toegankelijk is voor geauthenticeerde medewerkers heeft niet hetzelfde risicoprofiel als een publieke site. Zet de machine achter een privé-netwerk, vernauw de inputs, en de EOL-datum doet veel minder pijn dan de rewrite-offerte.

Dus: niet herschrijven. Wrappen.

De wrap-niet-herschrijven methode, in vier fases

De methode heeft vier fases. Geen ervan raakt de PHP-code of het database-schema aan.

  1. Identificeer de schermen die de workflows dragen.
  2. Verpak elke workflow in een typed API-endpoint dat de bestaande PHP-functies aanroept.
  3. Stel die endpoints beschikbaar als tools voor een agent.
  4. Schaduw-draaien, en zet het team daarna één workflow per keer over.

De eindsituatie is niet "geen PHP meer". De eindsituatie is: het operations team typt of spreekt wat het wil, de agent bedenkt welke tools in welke volgorde aangeroepen moeten worden, en de PHP-code doet hetzelfde databasewerk als altijd. De schermen zijn er nog als iemand naar één moet kijken. Ze zijn alleen niet meer de primaire interface.

Schermen koppelen aan workflows

Dit is het onderdeel dat de meeste teams overslaan en daarna betreuren. Voordat je iets wrapt, kijk je twee dagen mee met het operations team. Niet in een workshop. Aan hun bureau. Met een notitieboek.

Wat je zoekt is het verschil tussen een scherm en een workflow. Een scherm is wat de PHP rendert. Een workflow is wat de mens doet. "Factuur #4421 voor Acme afsluiten" is een workflow. Hij gebruikt het factuurscherm, het regelitemscherm, het klantscherm en mogelijk het creditnotascherm, in een volgorde die de mens uit zijn hoofd kent. Die volgorde staat nergens gedocumenteerd.

Je komt uit op een lijst die er zo uitziet, en die lijst is de echte spec voor het project:

  • Maandelijkse facturen afsluiten voor een klant (raakt: factuurlijst, factuurdetail, creditnota's, klantstatus).
  • Een nieuwe dealer onboarden (raakt: klant, adressen, prijscategorie, voorwaarden, contacten).
  • Een vastgelopen zending oplossen (raakt: order, zending, vervoerder, uitzonderingenlog).
  • Een prijsoverride goedkeuren (raakt: prijsregel, overrideverzoek, audit log).

Bij de distributeur waar wij binnenkwamen, kwam de volledige lijst uit op 31 workflows tegenover 142 schermen. De compressieratio doet ertoe. Je wrapt geen 142 dingen, je wrapt er 31, en je doet ze op volgorde van prioriteit. De maandafsluiting van twee dagen is workflow nummer één.

Een typed API voor de legacy

Voor elke workflow schrijf je één HTTP-endpoint dat een typed input ontvangt en een typed output teruggeeft. Het endpoint staat in een nieuwe directory naast de legacy-code en hergebruikt de bestaande PHP-functies via require_once. Geen ORM-herschrijving. Geen herarchitectuur. De legacy-code blijft in zijn eigen bestand.

<?php
// /api/v1/invoices/close.php
require_once __DIR__ . '/../../_bootstrap.php';
require_once LEGACY . '/modules/invoices/actions.php';

api_post(function (array $in, array $caller) {
    $errors = validate($in, [
        'invoice_id'   => 'int|required',
        'closing_date' => 'date|optional',
    ]);
    if ($errors) return api_error(400, $errors);

    try {
        $row = legacy_close_invoice(
            $in['invoice_id'],
            $in['closing_date'] ?? date('Y-m-d'),
            $caller['user_id']
        );
    } catch (LegacyDomainException $e) {
        return api_error(422, [
            'code'    => $e->getCode(),
            'message' => $e->getMessage(),
        ]);
    }

    return [
        'invoice_id' => (int)   $row['id'],
        'state'      =>         $row['state'],
        'closed_at'  =>         $row['closed_at'],
        'total_eur'  => (float) $row['total'],
        'pdf_url'    => render_invoice_url($row['id']),
    ];
});

Twee dingen om op te merken. Ten eerste doet het endpoint vrijwel geen business logic. Het parseert input, roept de bestaande PHP-functie aan, formatteert de output. De legacy-code blijft eigenaar van de regels. Ten tweede is de response een JSON-object met benoemde, getypeerde velden. Het PHP-scherm gaf een HTML-pagina terug met dezelfde data uitgesmeerd over het scherm. Het endpoint geeft de data op zichzelf terug.

Voor elk endpoint schrijf je ook een OpenAPI-fragment. Dit is wat de agent uiteindelijk te zien krijgt:

/invoices/close:
  post:
    operationId: closeInvoice
    summary: Close an invoice. Idempotent on invoice_id.
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [invoice_id]
            properties:
              invoice_id:   { type: integer }
              closing_date: { type: string, format: date }
    responses:
      '200':
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClosedInvoice' }
      '422':
        $ref: '#/components/responses/DomainError'

Een typed API van zo'n 30 endpoints komt uit op ongeveer 2.000 regels nieuwe PHP en 600 regels OpenAPI. Wij hebben hem in drie weken geschreven.

Tools, geen endpoints

De agent ziet de endpoints niet. Hij ziet tools. Het verschil doet ertoe.

Een endpoint is iets dat je aanroept. Een tool is iets met een naam, een beschrijving geschreven voor een niet-coder, en een contract over wat het doet met de wereld. Dezelfde payload, andere framing. De toolbeschrijving is de enige documentatie die de agent heeft, dus je schrijft hem alsof je schrijft voor een nieuwe collega die vanochtend is begonnen.

{
  "name": "close_invoice",
  "description": "Mark an invoice as final and immutable. Triggers PDF render, GL posting, and the customer email. Use ONLY after the operations lead has reviewed the line items. If the invoice has unresolved credit notes this fails with code CREDIT_PENDING; call list_credit_notes first.",
  "input_schema": {
    "type": "object",
    "required": ["invoice_id"],
    "properties": {
      "invoice_id": {
        "type": "integer",
        "description": "Numeric ID from the invoices module, not the customer-facing invoice number."
      }
    }
  }
}

De agent heeft nu ruwweg 30 tools. Een verzoek als "sluit de meifacturen voor Acme af behalve die met de betwiste regel" wordt een sequentie: list_invoices(customer="Acme", month="2026-05"), daarna list_disputes(invoice_ids=[...]), en dan close_invoice één keer per niet-betwiste ID. De agent bedenkt de sequentie. Die sequentie is precies wat de operations lead deed door zich door vier schermen heen te klikken.

Dit is waarom de scaffolding rond de agent belangrijker is dan het model. Op Hacker News liep deze week een draadje dat hetzelfde punt maakte in een agent-first frame: het model is een commodity, de scaffolding eromheen is het product. Voor het vervangen van een interne tool is de scaffolding de typed API plus de toolbeschrijvingen plus de human-in-the-loop regels. Heb je die op orde, dan maakt de modelkeuze nauwelijks uit.

Kernpunt

Je bouwt geen AI-feature. Je bouwt een typed contract over een 12 jaar oude codebase, en zet er een capabele operator voor.

Schaduw-draaien voordat je vertrouwt

Je laat de agent op dag één niet los op productie. Voor elke workflow draai je twee tot vier weken een schaduwperiode. De operations lead doet het werk op de oude manier, de agent doet het in een sandbox, en een diff-job vergelijkt de neveneffecten in de database na elke run. Tien achtereenvolgende schone diffs en je laat de agent het echt doen, met een bevestigingsstap voor elke destructieve actie.

De agent zal in de eerste week fout zitten. Niet voor 50%. Voor twee procent, op manieren die er precies uitzien als de andere 98%. De schaduwperiode is geen papierwerk. Het is de enige manier om de stille fouten te vangen voordat ze een klantgrootboek corrumperen.

Na 90 dagen productie met bevestigingen kun je de bevestiging meestal weghalen bij read-operaties en de veiligste mutaties. Een factuur afsluiten houdt voor altijd een bevestiging. Open orders opvragen niet.

Wat er na zes maanden over is

De PHP 7.4 codebase is ongewijzigd. Het MySQL-schema is ongewijzigd. De cronjobs draaien nog. De schermen werken nog; het operations team gebruikt er ongeveer tien van de originele 142 voor de gevallen die de agent nog niet heeft opgenomen, vooral long-tail edits, batchcorrecties, alles met een visuele layout die de agent nog niet in woorden kan beschrijven.

Het werk dat vroeger twee dagen klikken kostte, de maandelijkse factuurafsluiting, kost de operations lead nu 25 minuten gesprek met de agent en een eindgoedkeuring. Het rewritebudget dat €40k tot €60k zou worden, werd ruwweg €18k aan wrap-werk, en het resultaat is een systeem dat het team daadwerkelijk graag gebruikt.

De PHP 7.4 motor staat nog steeds op een tijdslot. Hij moet uiteindelijk uitgefaseerd worden, en de typed API maakt die uitfasering veel goedkoper als het zover is. Zodra je een contract voor de legacy hebt staan, kun je de implementatie achter het contract één endpoint per keer vervangen, met de agent als je regressietest. Dit is het strangler fig-patroon dat Martin Fowler in 2004 beschreef, toegepast op de API-laag in plaats van de UI-laag.

Toen wij deze laag bouwden voor de distributeur in Eindhoven, vroeg de operations lead de eerste maand wanneer de rewrite zou beginnen, en de tweede maand wanneer we het volgende scherm zouden opnemen. ABN doet dit soort AI-agent werk op PHP, Drupal en oude Magento-installaties; het wrap-patroon is hetzelfde, ongeacht de motor eronder.

Open je interne tool nu. Kies één workflow die meer dan tien klikken kost. Schrijf in één zin op wat de operator daadwerkelijk probeert te bereiken. Maak daarna een lijst van elk scherm dat hij aanraakt en elk veld dat hij invult. Is de zin korter dan twintig woorden en het klikpad korter dan veertig stappen, dan heb je een kandidaat om te wrappen, en kun je de methode in een week aan je team bewijzen.

Kern

Verpak een verouderde PHP-tool in een typed API en zet er een agent voor. De motor blijft, de UI sterft, en de rewrite wordt elke maand goedkoper.

FAQ

Stel je de rewrite hier niet gewoon mee uit?

Ja, bewust. Elk kwartaal dat je de motor laat draaien, leer je welke schermen daadwerkelijk gewicht dragen. Na twee jaar herschrijf je misschien 20% van de codebase. De rest laat je stilletjes wegsterven.

Hoe zit het met PHP 7.4 securitypatches?

Zet de machine achter een privé-netwerk, beperk inkomend verkeer tot de typed API-endpoints, en draai een web application firewall op de wrapper. Het aanvalsoppervlak krimpt tot een handvol JSON-endpoints die je volledig in de hand hebt.

Kan de agent data op dezelfde manier corrumperen als een junior operator?

Ja, en daarom geeft elke tool de regel terug die hij veranderd heeft, en leest de agent hem terug. Destructieve acties blijven de eerste 90 dagen achter een menselijke bevestiging. Schaduw-draaien vangt de stille fouten op voordat ze het grootboek raken.

Hoe lang duurt het om een scherm op te nemen?

Een halve dag tot een dag voor simpele CRUD. Twee tot vier dagen voor schermen met conditionele takken en neveneffecten. De bottleneck is meestal het schaduw-draaien, niet het coderen.

phplegacy sitesai agentsmigrationarchitecturemysql

Iets bouwen?

Start een project