+1 (276) 265-7197

Strangling a Legacy PHP App: Running New Code Beside Old Behind Apache

Every legacy PHP application eventually collects a list of things nobody will touch: the 4,000-line invoice.php, the admin built on a framework whose last release was 2014, the include file with global $db in it. The usual proposals are a full rewrite (nine months, then eighteen, then cancelled) or another year of patching. There is a third option, and it is the one we recommend on almost every engagement: put the new code beside the old code, move traffic across one route at a time, and delete the old route once the new one has proven itself in production.

That is the strangler pattern. On a LAMP stack it needs no new infrastructure and no rewrite — Apache already has everything required to run two applications behind one hostname.

What has to be true before you start

Do not begin a strangler migration on an unstable system. Stabilize first:

  • PHP is on a supported branch, or at least the upgrade is scheduled. Building new code against PHP 7.4 just creates a second legacy app.
  • Deploys are atomic and reversible. You are about to change routing in production repeatedly; each change needs a rollback measured in seconds.
  • You have request-level visibility — access logs with response times, and an error log somebody reads. You cannot compare old and new behaviour without numbers.

If those three are missing, fix them first. The routing work below is the easy part; doing it blind is what turns it into an outage.

Step 1: put a routing seam in front of the app

The seam is one Apache virtual host that owns the hostname and decides, per URL prefix, which application answers. The legacy app stays exactly where it is. The new app — say a Laravel or Symfony build in /srv/new, behind its own PHP-FPM pool — gets its own socket.

<VirtualHost *:443>
  ServerName app.example.com

  # New application: explicit, narrow prefixes only
  ProxyPass        /billing  http://127.0.0.1:8081/billing
  ProxyPassReverse /billing  http://127.0.0.1:8081/billing
  ProxyPreserveHost On

  # Everything else: the legacy app, untouched
  DocumentRoot /srv/legacy/public
  <FilesMatch \.php$>
    SetHandler "proxy:unix:/run/php/legacy.sock|fcgi://localhost"
  </FilesMatch>
</VirtualHost>

Two details that matter more than they look:

  1. The allowlist runs the right way round. Named prefixes go to the new app; the default is legacy. The reverse — "new app handles everything except this list" — means every unmapped route becomes a 404 the day you deploy.
  2. ProxyPreserveHost On, always. Without it the new app sees 127.0.0.1 as its host and generates redirects and absolute URLs pointing at localhost. This is the single most common first-day bug.

Reload Apache with apachectl configtest first, every time. A typo in a proxy line takes the whole hostname down, not just the new route.

Step 2: share sessions, or users get logged out at the boundary

The legacy app almost certainly uses default file-based sessions in /var/lib/php/sessions. The new app has its own session driver. Cross the boundary and the cookie is unrecognised — the user lands on a login page mid-checkout.

The clean fix is a shared session store both applications read. Redis or Valkey works well here; on Rocky and Alma 9 the packaged option is now valkey, a drop-in fork with the same protocol and client libraries, which matters if your policy requires a distro-supported package after Redis's 2024 licence change. Either way, the PHP configuration is the same shape:

; legacy app php.ini / pool config
session.save_handler = redis
session.save_path    = "tcp://127.0.0.1:6379?auth=..."
session.name         = APPSESSID
session.cookie_domain = .example.com

Then point the new app at the same store and the same cookie name, and configure its session driver to read PHP's serialization format — or, more simply, have the new app treat the legacy session as read-only identity data and keep its own state elsewhere. That asymmetry is usually worth it: one writer, one reader, no argument about serializers.

Verify it the blunt way. Log in on the legacy side, then curl a new-app route with the same cookie jar and confirm you get the page, not the login form:

curl -c jar -b jar -s -o /dev/null -w '%{http_code}\n' \
  https://app.example.com/billing/invoices

Step 3: let the database be the integration point

The temptation is to build an API between old and new. On an internal application with one MySQL instance, do not — at least not yet. Both applications talking to the same schema is simpler, transactional, and has no consistency lag. Rules that keep it honest:

  • One writer per table. Write down which application owns writes to each table you touch. Two writers with two sets of validation rules produce data that neither app can read correctly.
  • Give the new app its own MySQL user with grants limited to the tables it owns, plus SELECT on what it reads. This is cheap and it turns "we might have written to the wrong table" into a permission error.
  • Additive schema changes only during the migration. New columns and new tables, no renames or drops. The legacy app's SELECT * habits and hand-built queries will find any change you make; keep the old shape valid until the old code is gone.
  • Leave the ORM's opinions at the door. If the new framework wants to rename cust_id to customer_id, map it in the model. Renaming it in the database is a schema migration disguised as a style preference.

Step 4: move one route, and measure it

Pick the first route carefully. The right candidate is high-value enough that finishing it proves something, and self-contained enough that the blast radius is small — a report, a search page, an admin screen. Not checkout. Not login.

Cut it over in stages you can undo:

  1. Deploy the new route in the new app, reachable only at an internal URL. Compare it against the legacy route with real parameters.

  2. Add the ProxyPass line for a narrow path and let internal staff use it for a week. Location blocks with IP allowlists, or a header check with mod_rewrite, are enough to keep it staff-only.

  3. Open it to everyone. Watch the Apache access log for that prefix specifically — status codes and response times:

    awk '$7 ~ /^\/billing/ {print $9}' /var/log/httpd/access_log | sort | uniq -c
    
  4. Hold the state for a sprint. Nothing is finished until it has survived a month-end, a backup restore, and whatever cron job nobody remembers writing.

  5. Delete the legacy route's code. This step is not optional. A strangler migration that never deletes is just two applications to maintain, which is strictly worse than one.

Note the p95 for the route before and after. If the new implementation is slower, that is a finding, not a detail — legacy PHP that does one mysqli query is frequently faster than a fresh ORM issuing forty, and you would rather learn that on route one than route twenty.

What tends to go wrong

  • Shared includes. The legacy app's authentication or configuration lives in common.inc.php, and the new code reimplements it slightly differently. Read the old file line by line before you reimplement anything; the weird if block usually encodes a real business rule.
  • Cron and CLI paths. Routing only covers HTTP. Cron jobs, queue workers, and shell scripts still run against the legacy code and the same tables. Inventory them early — crontab -l for every user, plus /etc/cron.d.
  • Sticky redirects. The legacy app hardcodes Location: headers to /billing/old.php. Grep for them, and add mod_rewrite rules to catch what you miss, so bookmarks and emailed links do not break.
  • Uploads and generated files. If both applications write to /srv/legacy/uploads, make it one path via a bind mount rather than two copies drifting apart.
  • The stalled migration. Six routes moved, twenty to go, funding gone. This pattern is designed to be safe to stop — but decide deliberately that you are stopping, and document which app owns what. An undocumented half-migration is the worst state to inherit.

The honest version of the trade-off

The strangler pattern is slower than a rewrite on paper and faster in practice, because every increment ships and none of them require a launch date. It costs you a period of running two applications, one routing layer to reason about, and the discipline to delete old code.

If your application is small — a few thousand lines, one developer, no revenue at risk — a rewrite may genuinely be cheaper, and we will tell you so. What makes the difference is not code volume but how much undocumented behaviour is in there. If nobody can fully explain what the app does, you cannot specify a replacement, and incremental replacement behind a routing seam is the only approach that lets you find out while staying in production.

That work is our modernization and cloud moves practice: routing seams, session sharing, route-by-route cutovers, and deletion of the code we replaced. Tell us what your stack and versions look like and we will tell you where the first seam goes.