Reorganize a Legacy PHP API Without Breaking Old Apps

8 API folders, 102 PHP files and apps you cannot force-update. Moving them under /api/v1 with internal rewrites, response snapshots and a proof header.

Mobile apps make server cleanups hard. A web page can change its URLs whenever it likes. An app version installed two years ago will keep calling the exact path it was built with, and you cannot make every user update.

We had eight API folders sitting at the top of a shared-hosting document root (/Lotto/json/, /Takarakuji/json/, /MegaMillions/json/ and so on), each with its own copy of the database credentials, serving eight Flutter apps. The goal was one tidy tree, /api/v1/<app>/, with the credentials moved out of the web root, while every old URL kept working for every app version. This is how it went on 2026-11-08, with the numbers we measured.

Step 0: write down what "working" means

Before moving anything, we read the current app code and wrote every call it makes into a YAML file. That came to 95 endpoints, including the odd ones: directory-style URLs without a trailing slash, a POST with a JSON body, and requests with Japanese query values.

A small script then requested each one without following redirects and stored status, content type, Location, body hash and the JSON key structure:

200    28143  GET /starUnse/json/list.php?priDay=今日
301      795  GET /Lotto/json/latest
200     2579  GET /Lotto/json/grid.php?limit=30
301      795  GET /Takarakuji/json/lotto6/detail/section1
301      795  POST /Takarakuji/json/lotto6
...
saved: data/api_baseline/20261108-220202

Comparing immediately against itself gave 0 differences, so the comparison was stable enough to trust. Endpoints whose bodies change every call (AI predictions, "popular" lists) compare only status, type and JSON shape.

The baseline already showed two problems that existed before any change:

  • 32 calls went through a 301 first. The apps request /Lotto/json/latest and the server answers 301 → /Lotto/json/latest/ because latest is a directory. GET survives that with an extra round trip.
  • One POST could not work at all. POST /Takarakuji/json/lotto6 got a 301, and then a 403 because directory listing is disabled. Dart's HttpClient follows redirects only for GET and HEAD, so in the app this was simply an error. Nobody had noticed.

Step 1: one bootstrap instead of four include depths

Every endpoint loaded the database settings with a relative path whose depth depended on where the file sat:

Include line Files
require_once('../lib/AppConfig.php'); 37
require_once('../../lib/AppConfig.php'); 4
require_once('../../../lib/AppConfig.php'); 19
require_once('../../../../lib/AppConfig.php'); 41
require_once(__DIR__ . '/../lib/AppConfig.php'); 1

Moving folders would break all of these. Comparing hashes showed that the ten AppConfig.php copies on the server were identical, so a single copy was enough. It went to ~/tlabo_private/, outside the web root, and each endpoint now includes one bootstrap file:

<?php
// public_html/api/v1/_bootstrap.php
define('TLABO_PRIVATE', dirname(__DIR__, 5) . '/tlabo_private');
require_once TLABO_PRIVATE . '/AppConfig.php';
require_once TLABO_PRIVATE . '/clsMaria.php';
// proves which file answered; body is untouched, apps ignore it
if (!headers_sent()) { header('X-Tlabo-Api: v1'); }

A generator copied each folder's entire json/ tree (not just the files current apps use, because old versions may call others) and replaced only those two require_once lines with:

require_once($_SERVER['DOCUMENT_ROOT'] . '/api/v1/_bootstrap.php');

It refuses to continue if any require/include of lib/ survives, and runs php -l on every file. The first run stopped on HorseRace/json/api.php. The cause was a comment that mentioned lib/AppConfig.php, so the check was narrowed to include statements.

Step 2: test the new path before any app sees it

The new tree was uploaded next to the old one. Apps still used the old folders, so nothing changed for them. The snapshot tool can rewrite path prefixes, so each app's endpoints were requested at their new location and compared against the original baseline:

python scripts/api_snapshot.py compare --app kr_lottery --map /Lotto/json=/api/v1/lotto

All eight apps: 0 differences.

Step 3: rewrite only when the new file exists

The root .htaccess maps the old prefix to the new tree, but only if the target file actually exists. If it does not, no rule fires and the old folder answers exactly as before. That includes the broken POST, so this change does not alter its behaviour.

# kr_lottery
RewriteCond %{DOCUMENT_ROOT}/api/v1/lotto/$1/index.php -f
RewriteRule ^Lotto/json/(.*?)/?$ api/v1/lotto/$1/index.php [L,QSA]
RewriteCond %{DOCUMENT_ROOT}/api/v1/lotto/$1 -f
RewriteRule ^Lotto/json/(.+)$ api/v1/lotto/$1 [L,QSA]

Pointing directory-style requests straight at index.php has a side effect we wanted: /Lotto/json/latest now returns 200 directly instead of 301 then 200. Before touching the real root, the rules were tested in a throwaway subfolder on the same server. There a POST to a directory-style path returned 200 through the rewrite, which is exactly the case Dart could not handle with a redirect.

Step 4: switch, then prove it

The first app was switched alone. It was also the one with a real SQL injection: its list.php pasted $_GET['priDay'] straight into SQL. The fix used mysqli_real_escape_string, not prepared statements. With mysqlnd, prepared statements return integer columns as PHP integers, and json_encode then emits 12 instead of "12". That is a contract change older app versions might not survive. Escaping kept the output byte-identical.

Hostinger's WAF blocked the obvious test payloads (' OR '1'='1) with "Forbidden" before PHP ran, so we used a lone quote instead:

/api/v1/starunsejp/list.php?priDay=今日'   →  []  [200]

The unpatched file would have produced a SQL error. The patched one escapes it and finds nothing. Because the patch is the only file that answers that way, sending the same request to the old URL after the switch proved the rewrite was live.

The other seven apps were switched 23 minutes later in one .htaccess change. The results:

Check Result
App-critical URLs (ad config, ads.txt, app-ads.txt, APIs) 8/8
API regression on old paths, all 93 endpoints 0 differences
Full URL matrix (store URLs, redirects, pages) 53/53

The CDN almost fooled us

The proof-header check then failed on four URLs: no X-Tlabo-Api header, and the old 301 still there. The response headers explained it: x-hcdn-cache-status: HIT. Hostinger's CDN was caching API responses, including the pre-switch 301s. With a unique query string, all four came straight from the server: 200, header present, no redirect.

/Lotto/json/latest   cache: HIT  | bypass: HTTP/2 200 x-tlabo-api: v1 DYNAMIC

This had been true long before the migration. It means any data update can take a while to reach apps until the cache expires. That is worth knowing on its own.

What we would do again

  1. Build the endpoint list from the app code, not the server folder.
  2. Save a baseline that records redirects instead of following them.
  3. Remove relative includes with a single bootstrap, and keep secrets outside the web root.
  4. Rewrite only when the new file exists, so missing cases fall back to the old behaviour.
  5. Add a header that proves which code answered, and always test with a cache-busting query string.

The old folders stay on the server for two weeks in case something only an old app version calls turns up in the logs. After that they move out of the web root.

FAQ

Why not just 301 the old API paths to the new ones?

Mobile HTTP clients often do not follow redirects for POST. Dart's HttpClient follows them only for GET and HEAD, so a 301 on a POST endpoint becomes an error in the app. An internal rewrite keeps the old URL answering 200 with no redirect.

How do you prove the new file is answering and not the old one?

Make the new code emit a harmless response header (we used X-Tlabo-Api: v1) and check it on the old URL with a cache-busting query string. The body stays byte-identical, so apps are unaffected.

Why use escaping instead of prepared statements for the SQL injection fix?

With mysqlnd, prepared statements return integers and floats as native types, while query() returns strings. Switching would have changed numbers in the JSON from "12" to 12, which older app versions might not parse. Escaping fixed the injection with byte-identical output.

  • #php
  • #htaccess
  • #api
  • #mobile-apps
  • #flutter
  • #regression-testing

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑