Modernizing Legacy PHP Without a Rewrite
A complete rewrite is often the riskiest way to improve an old application. Incremental modernization protects working behavior while reducing the cost of future changes.
Inventory Before Changing
- Supported PHP and database versions
- Entry points, cron jobs, webhooks, and background scripts
- External integrations and undocumented file exchanges
- Authentication and permission paths
- Tables written by multiple modules
- Files containing credentials or environment-specific settings
- Production-only behavior and manual operator steps
Create a Safety Net
Start with characterization tests around important existing behavior. These tests do not claim the behavior is ideal; they record what the system currently does so refactoring does not change it accidentally.
<?php
declare(strict_types=1);
public function testLegacyQuoteCalculationRemainsCompatible(): void
{
$result = legacy_calculate_quote(
guestHours: 4.0,
deadheadHours: 1.5,
dayType: 'saturday'
);
self::assertSame(100500, $result['total_cents']);
}
Introduce Seams Around Old Code
Put new code around unstable dependencies first. Wrap direct mail calls, payment APIs, global database connections, and filesystem access behind small interfaces. Then move one use case at a time into a service.
Replace Global State Gradually
<?php
// Before
function find_customer($id) {
global $db;
return $db->query("SELECT * FROM customers WHERE id = " . (int) $id)->fetch();
}
// After
final class CustomerRepository
{
public function __construct(private PDO $pdo) {}
public function find(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, name, status FROM customers WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
}
Database Modernization
- Add primary keys and foreign keys where data permits.
- Convert implicit text dates to real date/time columns.
- Move money to integer cents or exact decimal types.
- Add indexes for actual production queries.
- Replace string-concatenated SQL with prepared statements.
- Create repeatable migrations before changing schema manually.
A Low-Risk Migration Plan
- Stabilize the runtime and back up the application and database.
- Add logging around important workflows.
- Upgrade dependencies and PHP in supported increments.
- Introduce Composer autoloading without moving every file at once.
- Route one feature through a new controller and service.
- Move its SQL into a repository and add tests.
- Repeat feature by feature.
- Remove old entry points only after traffic and logs prove they are unused.
Rewrite warning: Old applications contain hidden requirements encoded in years of bug fixes and operational workarounds. A rewrite discards that knowledge unless it is deliberately recovered.