Modernizing Legacy PHP Without a Rewrite
Old PHP doesn't scare me. I started out modifying PHP-Nuke modules, after all. What scares me is the urge to rewrite it all from scratch. A full rewrite is usually the riskiest way to improve an old app. Modernizing it piece by piece keeps what works working while making future changes cheaper.
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 the important stuff. They don't say the current behavior is right, they just pin down what it does today, so you notice if a refactor changes it by accident. Sometimes the "bug" turns out to be something a customer depends on.
<?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
Start by wrapping the risky dependencies: direct mail() calls, payment APIs, that global database connection, filesystem access. Put each behind a small interface. Then move one feature at a time into a proper service. Slow and boring wins here.
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.
if statement exists because something broke once. A rewrite throws all of that away unless you go dig it out first.