Article

Inheriting a Legacy PHP Application

A first-30-days playbook for developers handed an unfamiliar PHP codebase: runtime truth, risk triage, and safe change.

Inherited PHP apps arrive with tribal myths (“it only runs on Steve’s VM”), missing Composer metadata, and production hosts nobody wants to reboot. Your job is to replace myths with measurements before rewriting anything fashionable. This playbook covers the first roughly thirty days: establish runtime truth, triage risk, stabilize operations, then choose a ladder—not a slogan.

Day 0: freeze irreversible moves

Before “quick fixes,” agree on ground rules with whoever owns production:

  • No PHP version flips on the live pool until staging matches production extensions and data shape.
  • No composer update on production; prefer composer install from a committed lockfile.
  • No schema changes without a restore rehearsal.
  • Capture a backup/tag of the code and database you inherited—even if the repo is messy.

Day 1–3: establish runtime truth

You cannot modernize what you cannot name. Inventory every execution surface and the PHP that actually serves it.

  • Identify every SAPI and cron entry; capture php -v, php -m, and effective ini paths for web and CLI (they often differ).
  • Follow determine PHP version techniques for hosts without shell access.
  • Export Composer lock / platform constraints; read legacy Composer and the Composer center.
  • Snapshot error logs for a busy hour—map signatures in PHP Errors.
  • Sketch the deploy path: who SSHes, which panel selector, which container tag, which shared host.
  • Note framework generation if present (ZF1 vs ZF2/ZF3 vs Laminas vs “custom MVC”) using ZF versions overview when relevant.
# TRANSITIONAL — capture production truth without changing it
php -r 'echo PHP_VERSION, PHP_EOL;'
php -m
php --ini
# store outputs beside the repo under ops/inventory/
# also capture: crontab -l, systemd timers, panel PHP selector screenshots

Day 4–7: map the application surface

Draw a boring but accurate map:

  1. Document roots and front controllers
  2. Admin vs public vs API entry points
  3. Authentication and session configuration
  4. Database access layer (mysql_*, mysqli, PDO, ORM)
  5. File upload / export directories and whether they are web-reachable
  6. Outbound email, payment, and SOAP/REST integrations
  7. Cron and queue workers

Store the map in the repo (ops/architecture.md or similar). Prefer access logs over opinions when deciding what is “unused.”

Day 4–10: risk triage with scanners

Run compatibility and deprecated scanners. Rank findings with a simple severity model:

  • P0 — removed APIs that prevent the next PHP major (mysql_*, each, create_function, …)
  • P1 — security-sensitive patterns on auth/money/upload paths
  • P2 — deprecations that become fatals on the following minor
  • P3 — style and structure debt that does not block runtime

Start the legacy PHP audit checklist. Note authentication, payment, and file-upload surfaces first. Cross-check security basics in PHP security modernization without turning week one into a full rewrite.

# LEGACY hunt (adjust roots to your tree)
find . -name '*.php' -print0 | xargs -0 grep -nE \
  'mysql_|mcrypt_|ereg|split\(|each\(|create_function|eval\(|unserialize\('

Day 8–14: stabilize operations before features

Inherited apps often fail first on operations, not elegance:

  • Turn off public error display; confirm logs land somewhere humans read (error handling, error reporting generator).
  • Document how to rebuild the app on a clean VM/container.
  • Add a minimal smoke script: login, one read path, one write path, one cron dry-run.
  • Compare staging vs production PHP/extensions with environment compare and config diff.
  • If Composer is absent, add it in a transitional way (adding Composer) without rewriting includes on day one.
# MODERN — tiny smoke sketch (CLI)
# bin/smoke.php — exit non-zero on failure
# 1) bootstrap app
# 2) fetch a known record
# 3) verify auth container boots
# 4) print OK

Day 11–21: choose a ladder, not a rewrite proposal

Pick the next runtime milestone from the migration center using evidence:

  1. Add smoke tests around money paths and login before touching them.
  2. Delete dead entry points you can prove unused (access logs > opinions).
  3. Schedule one vertical slice (for example one admin report) for PDO/prepared statements rather than a global rewrite.
  4. Publish a one-page “how we deploy PHP” doc so the next inheritor is faster than you were.

Day 15–30: security and dependency hygiene (parallel track)

Modernization fails if credentials and sessions stay in 2008 mode. In parallel with the runtime ladder:

Do not claim “secure” after one PR. Track closed P0/P1 items explicitly.

Communication artifacts that prevent thrash

Leave these in the repository by day 30:

  • ops/inventory/ — PHP versions, modules, ini paths
  • ops/entrypoints.md — web/CLI/cron
  • ops/upgrade-ladder.md — current → next → target supported PHP
  • ops/rollback.md — image tags, DB restore owner, abort criteria
  • Link hub for humans: this page, audit, and the chosen version guide

Anti-patterns to refuse early

  • Big-bang framework rewrite before the app boots under a supported PHP
  • Permanent --ignore-platform-reqs
  • Lowering error_reporting to hide migration signal
  • Trusting staging that does not match production extensions
  • Calling ZF1 “PHP 8 compatible” without measured proof

Next reads: version migration center · testing legacy PHP · incremental modernization · security modernization.

Stakeholder questions to answer in week one

Technical inventory fails if business context is missing. Ask early:

  • What are the revenue-critical paths (checkout, billing export, partner API)?
  • Who can approve downtime or read-only windows?
  • Where are the real secrets (panel, password manager, old wiki)?
  • Which integrations fail loudly vs silently?
  • Is there an existing contract requiring a PHP version or framework?

Write answers into ops/context.md. They drive ladder priorities more than aesthetic architecture goals.

Data and backup truth

Inherited apps often have backups that have never been restored. Before schema or charset work (especially utf8mb4 conversions described in PHP 5 to modern PHP):

  1. Identify backup system and retention
  2. Restore to an isolated host
  3. Boot the app against the restored DB
  4. Record time-to-restore and gaps

If restore fails, modernization pauses until backup reality is fixed—otherwise every migration is gambling.

People process: make the app teachable

By the end of day 30, a second engineer should be able to:

  • Run the app locally or in the standard container
  • Execute smoke tests
  • Deploy using the written runbook
  • Find logs and invert a bad release

If only one person can deploy, you have not finished the inheritance phase even if code improved. Link the runbook from the repository README and from legacy audit notes.

Tooling kit for the first month

Sample 30-day calendar

  • Days 1–3: runtime inventory, access, backups identified
  • Days 4–7: architecture map, log sampling, scanner baseline
  • Days 8–14: ops stabilization, Composer bootstrap, smoke tests
  • Days 15–21: ladder decision, first P0 removed-API fixes on a branch
  • Days 22–30: security P0/P1 progress, staging PHP candidate soak, rollback doc signed off
# MODERN — branch naming that matches the ladder
# chore/inventory-runtime
# fix/remove-mysql-from-login
# chore/composer-bootstrap
# test/smoke-login-checkout

Definition of done for “inheritance complete”

You can close the inheritance phase when:

  • Runtime truth is documented for web and CLI
  • P0 scanner findings are listed with owners
  • Smoke tests exist for critical paths
  • Deploy + rollback are written and rehearsed once
  • Next PHP/framework milestone is chosen with a dated plan
  • Security display_errors / secrets / session cookie basics are not actively on fire

That is enough to begin deeper modernization without mythology.

Working with partial source and missing history

Sometimes you inherit a tarball without git history. Create a repository immediately, commit the tarball as-is, then invent history going forward. Do not wait for the “real” repo to be found. Tag inherited-as-received before your first cleanup commit so you can always diff against the original tree.

When credentials are embedded in the tarball, rotate them after the first secure backup—do not assume obscurity of the archive protects production. Move secrets as described in PHP security modernization while you continue inventory.

Framework recognition cheatsheet

  • Underscore controllers + application.ini → likely ZF1 → ZF1
  • Zend\Mvc modules + Composer ZF packages → ZF2/ZF3 → Laminas migration
  • laminas/ packages already → maintain/upgrade under Laminas hub
  • No framework, many includes → Composer + runtime ladder first

Wrong identification wastes weeks. Verify with files on disk, not with what the previous vendor called it in email.

Exit criteria checklist (copy into the ticket)

# TRANSITIONAL — inheritance exit checklist
# [ ] php -v / php -m captured for FPM and CLI
# [ ] entrypoints listed (web, cron, workers)
# [ ] scanner baseline attached
# [ ] smoke tests for login + one money path
# [ ] deploy + rollback doc rehearsed
# [ ] next milestone chosen (PHP and/or framework)
# [ ] display_errors off in production; logs verified

Keep the first month boring on purpose

Resist the urge to introduce microservices, new ORMs, or a full design-system rewrite during inheritance. Boring inventory, boring smoke tests, and a boring rollback plan create the platform on which later ambitious work can succeed. If a stakeholder pushes for a rewrite proposal before runtime truth exists, share this playbook and the scanner baseline instead of arguing from opinion.

Measure first, then modernize with a written ladder.

Related reading