← Blog

PHP

Retiring a PHP 7.4 internal tool: wrap, don't rewrite

A 12-year-old PHP 7.4 tool runs the back office. Nobody dares rewrite it. Here is the method we use to retire its UI without touching the engine.

Jacob Molkenboer· Founder · A Brand New Company· 18 Oct 2024· 9 min
Worn cloth-bound manual with brass key, tracing paper overlay, green ribbon bookmark, red wax seal on ivory surface.

The €40k question nobody wants to answer

The operations lead at a €9M distributor in Eindhoven is on day two of clicking through 412 invoice rows in a PHP 7.4 admin panel built in 2014. She has another half-day before the month closes. The tool works. Nobody dares touch it. The original developer left in 2017, the codebase is eight modules and 142 screens of unmaintained PHP, and the rewrite quote sitting in the CFO's inbox is €40k to €60k for four to six months of work.

When we walked in last September, the brief was the usual one: "we need to rewrite this on a modern stack". The CFO said no to the quote. We agreed with her.

Eight months later, the operations lead uses the original UI maybe five hours a week. The other thirty-five hours she spends talking to an agent that drives the same backend through a typed API we built in front of it. The PHP 7.4 code is unchanged. The MySQL schema is unchanged. The rewrite still hasn't happened. The case for it gets weaker every month.

Why a rewrite is the wrong first move

The reflex to rewrite a legacy internal tool is almost always wrong, and the reason is in the word "internal". Customer-facing systems get rewritten because the customer experience is the product. Internal tools get rewritten because someone in management read about technical debt and got nervous.

The first six months of any rewrite produce a worse tool. Workflows the original developer encoded over five years of small bug fixes vanish on day one and reappear as support tickets for the next eighteen months. The team that used the old tool with muscle memory now needs training.

The institutional knowledge in the PHP code gets lost in translation. Every internal app of this age has at least one screen where a button does seven things, and at least three of those things are load-bearing for a process nobody documented. A rewrite either reimplements all seven (and inherits the same mess) or picks four and breaks the other three.

And the PHP 7.4 engine still works. PHP 7.4 stopped getting security patches in November 2022, but a tool sitting behind a firewall, accessed only by authenticated staff, is not the same risk surface as a public-facing site. Move the box behind a private network, narrow its inputs, and the EOL date matters a lot less than the rewrite quote.

So: don't rewrite. Wrap.

The wrap-don't-rewrite method, in four phases

The method has four phases. None of them touch the PHP code or the database schema.

  1. Identify the screens that carry the workflows.
  2. Wrap each workflow in a typed API endpoint that calls the existing PHP functions.
  3. Expose those endpoints as tools to an agent.
  4. Shadow-run, then cut the team over one workflow at a time.

The end state is not "no more PHP". The end state is: the operations team types or speaks what they want, the agent figures out which tools to call in what order, and the PHP code does the same database work it always did. The screens are still there if someone needs to look at one. They just stop being the primary interface.

Mapping screens to workflows

This is the part most teams skip and then regret. Before you wrap anything, you watch the operations team work for two days. Not in a workshop. At their desks. With a notebook.

What you are looking for is the difference between a screen and a workflow. A screen is what the PHP renders. A workflow is what the human does. "Close out invoice #4421 for Acme" is a workflow. It uses the invoice screen, the line-items screen, the customer screen, and possibly the credit-notes screen, in a sequence the human has memorised. None of that sequence is documented anywhere.

You will end up with a list that looks like this, and the list is the real spec for the project:

  • Close monthly invoices for a customer (touches: invoice list, invoice detail, credit notes, customer status).
  • Onboard a new dealer (touches: customer, addresses, pricing tier, terms, contacts).
  • Reconcile a stuck shipment (touches: order, shipment, carrier, exceptions log).
  • Approve a price override (touches: pricing rule, override request, audit log).

For the distributor we walked in to, the full list was 31 workflows against 142 screens. The compression ratio matters. You are not wrapping 142 things, you are wrapping 31, and you wrap them in priority order. The two-day month close is workflow number one.

A typed API in front of the legacy

For each workflow, you write one HTTP endpoint that takes a typed input and returns a typed output. The endpoint lives in a new directory next to the legacy code and reuses the existing PHP functions through require_once. No ORM rewrite. No re-architecting. The legacy code stays in its file.

<?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']),
    ];
});

Two things to notice. First, the endpoint does almost no business logic. It parses input, calls the existing PHP function, formats the output. The legacy code keeps owning the rules. Second, the response is a JSON object with named, typed fields. The PHP screen returned an HTML page with the same data smeared across it. The endpoint returns the data on its own.

For each endpoint you also write an OpenAPI fragment. This is what the agent will eventually see:

/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'

A typed API of around 30 endpoints comes out to roughly 2,000 lines of new PHP and 600 lines of OpenAPI. We wrote it in three weeks.

Tools, not endpoints

The agent does not see the endpoints. It sees tools. The difference matters.

An endpoint is a thing you call. A tool is a thing with a name, a description written for a non-coder, and a contract about what it does to the world. Same payload, different framing. The tool description is the only documentation the agent has, so you write it like you are writing for a new hire who arrived this morning.

{
  "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."
      }
    }
  }
}

The agent now has roughly 30 tools. A request like "close out the May invoices for Acme except the one with the disputed line" becomes a sequence: list_invoices(customer="Acme", month="2026-05"), then list_disputes(invoice_ids=[...]), then close_invoice called once for each non-disputed ID. The agent figures out the sequence. The sequence is exactly what the operations lead used to do by clicking through four screens.

This is why the scaffolding around the agent matters more than the model. There was a Hacker News thread this week making the same point in an agent-first frame: the model is commodity, the scaffolding around it is the product. For an internal tool replacement, the scaffolding is the typed API plus the tool descriptions plus the human-in-the-loop rules. Get those right and the model choice barely matters.

Takeaway

You are not building an AI feature. You are building a typed contract over a 12-year-old codebase, and putting a competent operator in front of the contract.

Shadow-running before you trust

You do not let the agent loose on production on day one. For each workflow you run a shadow period of two to four weeks. The operations lead does the work the old way, the agent does it in a sandbox, and a diff job compares the database side-effects after each run. Ten consecutive clean diffs and you let the agent do it for real, with a confirmation step in front of every destructive action.

The agent will be wrong in the first week. Not 50% wrong. Two percent wrong, in ways that look exactly like the other 98%. The shadow period is not paperwork. It is the only way to catch the silent failures before they corrupt a customer ledger.

After 90 days of production with confirmations, you can usually drop the confirmation for read operations and the safest mutations. Closing an invoice keeps the confirmation forever. Listing open orders does not.

What's left after six months

The PHP 7.4 codebase is unchanged. The MySQL schema is unchanged. The cron jobs still run. The screens still work; the operations team uses about ten of the original 142 for the cases the agent hasn't absorbed yet, mostly long-tail edits, batch corrections, anything with a visual layout the agent cannot yet describe in words.

The work that used to take two days of clicking, the monthly invoice close, now takes the operations lead 25 minutes of conversation with the agent and a final approval. The rewrite budget that was going to be €40k to €60k turned into roughly €18k of wrapping work, and the result is a system the team actually likes using.

The PHP 7.4 engine is still on a clock. It will need to be retired eventually, and the typed API makes that retirement much cheaper when the time comes. Once you have a contract in front of the legacy, you can swap the implementation behind the contract one endpoint at a time, with the agent as your regression test. This is the strangler fig pattern Martin Fowler described in 2004, applied at the API layer instead of the UI layer.

When we built this layer for the Eindhoven distributor, the operations lead spent the first month asking when the rewrite would start and the second month asking when we'd absorb the next screen. ABN does this kind of AI agent work across PHP, Drupal and old Magento installs; the wrapping pattern is the same regardless of the engine underneath.

Open your internal tool right now. Pick one workflow that takes more than ten clicks. Write down, in one sentence, what the operator is actually trying to accomplish. Then list every screen they touch and every field they fill in. If the sentence is under twenty words and the click-path is under forty steps, you have a candidate to wrap, and you can prove the method to your team in a week.

Key takeaway

Wrap a legacy PHP tool in a typed API and put an agent in front. The engine stays, the UI dies, and the rewrite gets cheaper every month.

FAQ

Doesn't this just defer the rewrite?

Yes, deliberately. Every quarter you keep the engine running you learn which screens actually carry weight. After two years you might rewrite 20% of the codebase. The rest you let die quietly.

What about PHP 7.4 security patches?

Move the box behind a private network, restrict inbound traffic to the typed API endpoints, and run a web application firewall on the wrapper. The attack surface shrinks to a handful of JSON endpoints you fully control.

Can the agent corrupt data the same way a junior operator could?

Yes, which is why every tool returns the row it changed and the agent reads it back. Destructive actions stay behind a human confirmation for the first 90 days. Shadow-running catches silent failures before they hit the ledger.

How long does it take to absorb a screen?

Half a day to a day for simple CRUD. Two to four days for screens with conditional branches and side effects. The bottleneck is usually shadow-running, not the coding.

phplegacy sitesai agentsmigrationarchitecturemysql

Building something?

Start a project