Move off PHP one route at a time — with proof
Inventory a PHP app in your browser, run each ported route beside PHP and compare every response, cut over on the evidence, roll back in a second, then switch php-fpm off.
Nothing changes until you choose. Every route stays on PHP. You write a port, it runs beside PHP and every response is compared, and you cut over only when the evidence says they match. A rollback takes about a second. When no route is left, you switch php-fpm off.
Your files are read in this browser and never uploaded. Detection reads the code, it does not run it: the plan says "detected", not "proven".
How it works
1 · Inventory
Routes, tables (readers and writers), sessions, uploads, cron, secrets and libraries, ordered in four waves. A scaffold defines every route as notPorted, so nothing moves yet.
2 · Shadow
PHP answers. The port runs on the same request off the hot path, in dry-run mode, and the two responses are compared under explicit rules. Only locations are kept, never bodies.
3 · Cut over
When enough requests over enough days match, an operator signs the cut-over with a KeyZ key. The node records LegacyRouteCutOver with the report hash.
4 · Roll back
Any time, with a reason: LegacyRouteRolledBack, and the evidence starts again. Unported routes never pass through the host: nginx sends them straight to PHP.
Measured on one machine
The example orders API and its port, with the same request mix (60,000 requests, 64 concurrent, 4 load processes). Intel Core i9-14900KS (32 threads), WSL2 Linux 5.15, x86-64 — not the ARM instance type.
- Deployment
- req/s
- p50 ms
- p95 ms
- p99 ms
- PHP today (nginx → php-fpm, 8 workers)
- 19,114
- 3.31
- 3.73
- 4.46
- PHP tuned (32 workers)
- 34,662
- 1.68
- 2.92
- 4.6
- Shadow (temporary): PHP answers, the port is compared beside it, 4 processes
- 11,249
- 5.05
- 10.56
- 12.82
- Port served (nginx → route host, 4 processes, no PHP)
- 45,593
- 1.29
- 2.44
- 3.96
- Port served, no nginx hop
- 50,804
- 1.13
- 2.02
- 3.45
Shadow evidence for GET /api/orders.php: 84,051 of 84,051 responses matched, 0 mismatches, 0 port errors. The handler took 0.28 ms (p50); the PHP round trip took 2.23 ms.
How to read it: the served port did 45,593 req/s, against 34,662 for PHP tuned to 32 workers (1.3×) and 19,114 with 8 workers. Shadow mode is slower because it does both jobs and compares them. It is temporary, and it can sample. Cost per million requests is a fraction of a cent either way ($0.0033 vs $0.0044; this 32-thread machine priced at $0.544/hour (16 × a 2-vCPU instance at $0.034/hour) — replace with your instance price). The case for moving is not the speed. It is that each step is proven, recorded and reversible.
Where PHP failed and the port held
100 orders posted 50 at a time, 3 rounds, then the list read back (nginx → php-fpm 8.4 (32 workers, pm static) → examples/php-app; nginx → 4 route-host processes (reuse-port, serve mode) → examples/port/routes.ts, one signed ContentCreated TX per order in edge-txs.jsonl).
- Sent
- Confirmed (201)
- Kept
- Confirmed but lost
- Failed (500)
- PHP (php-fpm, 32 workers)
- 300
- 88
- 12
- 76
- 212
- CAPPZ port (4 processes)
- 300
- Lost writes. PHP reads the whole file, appends and writes it back, so overlapping requests overwrite each other, and it answers 201 whether or not the save worked. The port writes one signed TX per order, appended to the node log: a confirmed write cannot be overwritten, and a write it cannot record is refused (503), never confirmed.
- Crashes under load. PHP requests that read a half-written file threw TypeError and returned 500. The port failed none.
- Revoked keys and replays. On 12,051 signed requests, PHP's own signature check let through 360 requests from revoked keys and 359 replays; KeyZ Gate refused every one, and was looser on 0 requests. KeyZ Gate in front of a PHP route.
The shadow does not find lost writes: PHP answers 201 for them, so the responses match. It counts PHP's own 5xx separately from the port. Before a write route cuts over, run write-race.ts against a copy of the data. Same code on php -S with 8 workers: 83 confirmed, 39 kept; 76 confirmed, 36 kept. measured on x86-64 WSL2, not ARM.
What moves where
- Request handlers
- TypeScript handlers on the edge node (defineRoute), shadowed and cut over one by one
- Signature checks, sessions
- KeyZ Gate in front; handlers get the verdict (ctx.verdict) — passkeys instead of PHPSESSID
- MySQL / Postgres tables
- Wallet TXs and replayed views; the plan lists each table's readers and writers, and a table moves whole after its last PHP writer
- Writes during shadow
- ctx.emit() — counted in shadow, signed by the node key and written only after cut-over
- File uploads
- The DMZ quarantine (receipts, scan, release)
- Cron
- BPMN timers on the edge node (jobs.ts); every TX they write is signed
- Calls that need a third-party secret
- Last (wave 4); hosted only where the secret forces it
- Public HTTPS
- Unchanged: the certificate stays on nginx/Caddy
Use it on your app
The whole move, step by step
# 0 · the CAPPZ repo next to your app (Deno 2) git clone https://github.com/CAL-CAPPZ-Attribution-Liberation/graph-ledger-notary cappz && cd cappz # 1 · inventory + scaffold — reads code and logs, changes nothing deno run -A packages/cappz-edge-node/tools/php-port.ts inventory --src /var/www/app \ --log /var/log/nginx/access.log --crontab <(crontab -l) --out ./port # → port/PLAN.md port/routes.ts (every handler notPorted) port/nginx.conf port/inventory.json # 2 · the route host, with PHP behind it on an internal port (port/nginx.conf, part 1) CAPPZ_NODE_DATA=./node-data CAPPZ_ROUTE_OPERATORS=<operator wallet> \ deno run -A --unstable-net packages/cappz-edge-node/tools/php-port.ts serve \ --port 41450 --routes ./port/routes.ts --upstream http://127.0.0.1:8080 --data ./node-data # 3 · write a handler in port/routes.ts; send only ported routes through the host deno run -A packages/cappz-edge-node/tools/php-port.ts nginx > /etc/nginx/cappz-routes.conf && nginx -s reload # 4 · watch the evidence (every response compared; nothing written in shadow) deno run -A packages/cappz-edge-node/tools/php-port.ts status # READY serve GET /api/orders.php 84051 compared 100.00% match 0 mismatch 0 errors # a write route: also send overlapping writes to copies of the data and count what was kept deno run -A packages/cappz-edge-node/tools/write-race.ts --base php=http://127.0.0.1:8080 \ --base port=http://127.0.0.1:41450 --path /api/orders.php --requests 100 --concurrency 50 # 5 · an operator key once, then cut over on the report; roll back any time deno run -A packages/cappz-edge-node/tools/php-port.ts operator --out operator.json --data ./node-data deno run -A packages/cappz-edge-node/tools/php-port.ts cutover --route "GET /api/orders.php" --operator operator.json deno run -A packages/cappz-edge-node/tools/php-port.ts rollback --route "GET /api/orders.php" --operator operator.json --because "…" # 6 · when status shows no legacy route left: location / → the route host, then sudo systemctl disable --now php8.4-fpm
The example PHP route
Its port, and the differences shadowing found
In your own code: @cappz/sdk/port
What it does not do
- It does not translate PHP into TypeScript. You write each handler; the scaffold, the plan and the comparison make that small and safe.
- Detection is lexical. Dynamic includes, generated SQL and routes added at run time can be missed; the access log catches the paths no definition explains.
- For routes that write, the port runs after PHP has written, so values like the next id differ by design. Declare them as rules; the effects are counted, never written, until cut-over.
- Public HTTPS keeps its certificate on nginx/Caddy. Measured on x86-64, not on your instance type: run bench-routes.ts there.
Retiring only a PHP signature check is a smaller step: see KeyZ Gate in front of a PHP route