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 updateon production; prefercomposer installfrom 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:
- Document roots and front controllers
- Admin vs public vs API entry points
- Authentication and session configuration
- Database access layer (
mysql_*, mysqli, PDO, ORM) - File upload / export directories and whether they are web-reachable
- Outbound email, payment, and SOAP/REST integrations
- 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:
- If still on PHP 5-era APIs → follow PHP 5 to modern PHP.
- If on PHP 7.x → use PHP 7 to PHP 8 and the checklist.
- If ZF2/ZF3 → plan ZF→Laminas as its own sequenced program.
- If ZF1 → containment first (ZF1 + PHP 8), not laminas-migration.
- Add smoke tests around money paths and login before touching them.
- Delete dead entry points you can prove unused (access logs > opinions).
- Schedule one vertical slice (for example one admin report) for PDO/prepared statements rather than a global rewrite.
- 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:
- Password storage plan (password hashing, password hash tool)
- Session cookie flags (sessions)
- CSRF on state-changing forms
composer auditand abandoned package review (guide)- Secrets out of the document root
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 pathsops/entrypoints.md— web/CLI/cronops/upgrade-ladder.md— current → next → target supported PHPops/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_reportingto 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):
- Identify backup system and retention
- Restore to an isolated host
- Boot the app against the restored DB
- 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
- PHP Version Compatibility Checker
- PHP Deprecated Checker
- Composer Platform Checker
- Composer.json Validator
- PHP Environment Compare
- Config Diff
- php.ini Generator
- Error Reporting Generator
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\Mvcmodules + Composer ZF packages → ZF2/ZF3 → Laminas migrationlaminas/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
- Composer Modernization Center Add Composer to legacy PHP projects, migrate includes to autoloading, set platform constraints, and replace a…
- PHP 5 to Modern PHP: Complete Incremental Migration Guide A deep, production-minded path from PHP 5.x codebases to supported PHP 8.x: removed extensions, charset, PDO,…
- PHP Security Modernization for Legacy Applications Upgrade inherited PHP security practices: prepared statements, password hashing, sessions, CSRF, XSS escaping…
- PHP 7 to PHP 8 Migration Guide Deep guide to PHP 8.0 breaking changes that matter for PHP 7 applications, with upgrade tactics through suppo…
- Manual Includes to Composer Autoloading Stage a migration from require/include trees to Composer classmap and PSR-4 without deleting every include on…