Software Teams

Legacy Code Modernization: Practical Approaches That Work

Strategies for modernizing legacy codebases incrementally, from characterization tests and refactoring to gradual framework upgrades.

The Rewrite Trap

When developers encounter a legacy codebase, the first impulse is usually "let us rewrite it from scratch." This almost never works. The original system encodes years of business rules, edge cases, and hard-won bug fixes. A rewrite discards all of that institutional knowledge and replaces it with a new system that will take years to reach the same level of correctness.

Instead, modernize incrementally. Improve the system while it continues to deliver value.

Assessing the Situation

Before writing any code, understand what you are working with.

Code Archaeology

  • Read the git history. Which files change most frequently? These are your hotspots and your highest-priority modernization targets.
  • Run static analysis. Tools like PHPStan, Psalm, or SonarQube quantify code quality issues and highlight the worst areas.
  • Map the dependencies. Which classes depend on which? Identify tightly coupled components that will resist change.
  • Talk to the people who built it. If they are still around, they know where the bodies are buried.

Risk Assessment

Classify areas of the codebase:

  • High change frequency, high complexity: Modernize these first. They consume the most developer time and produce the most bugs.
  • High change frequency, low complexity: Quick wins. Clean up coding standards, add type hints, improve naming.
  • Low change frequency, high complexity: Leave these alone unless they are causing problems. The ROI of modernizing code nobody touches is near zero.
  • Low change frequency, low complexity: Ignore. Spend your energy where it matters.

Step 1: Establish a Safety Net

You cannot refactor safely without tests. Legacy code typically has few or no tests, so your first task is creating them.

Characterization Tests

Characterization tests do not verify that behavior is correct. They capture what the code currently does so you can detect when changes alter that behavior.

public function test_invoice_total_calculation(): void
{
    $invoice = $this->createInvoiceFromFixture('legacy-invoice-fixture.json');

    $total = $invoice->calculateTotal();

    // We do not know if 1247.83 is "correct" but it is what the system
    // currently produces. Any change to this value means we altered behavior.
    $this->assertEquals(1247.83, $total);
}

Write characterization tests for every piece of code you plan to modify. Focus on public interfaces and integration points, not internal implementation details.

Approval Tests

For complex outputs (HTML pages, PDF reports, API responses), use approval testing. Capture the current output, save it as the "approved" version, and fail if future output differs.

public function test_invoice_pdf_output(): void
{
    $invoice = Invoice::find(42);
    $pdf = $this->generatePdf($invoice);

    $this->assertMatchesSnapshot($pdf->toHtml());
}

Step 2: Introduce Structure Gradually

Extract Method

The most basic and safest refactoring. Take a block of code inside a long method and extract it into a named method:

// Before: 200-line method
public function processOrder(array $data): void
{
    // 40 lines of validation
    // 30 lines of price calculation
    // 50 lines of inventory checks
    // 80 lines of order creation and notification
}

// After: clear structure, each method independently testable
public function processOrder(array $data): void
{
    $this->validateOrderData($data);
    $pricing = $this->calculatePricing($data);
    $this->verifyInventory($data['items']);
    $this->createOrder($data, $pricing);
    $this->notifyStakeholders($order);
}

Each extracted method can now be tested independently and reasoned about in isolation.

Introduce Parameter Objects

Legacy code often passes long lists of parameters between functions:

// Before
function createUser($name, $email, $phone, $address, $city, $country, $role, $department) { ... }

// After
function createUser(CreateUserData $data) { ... }

This makes the interface clearer and eliminates parameter ordering bugs.

Replace Conditionals with Polymorphism

Legacy code accumulates conditionals over years. Switch statements with 15 cases and if/elseif chains that span hundreds of lines are common.

// Before
if ($type === 'invoice') {
    // 40 lines of invoice logic
} elseif ($type === 'credit_note') {
    // 35 lines of credit note logic
} elseif ($type === 'quote') {
    // 30 lines of quote logic
}

// After
interface DocumentProcessor {
    public function process(Document $document): void;
}

class InvoiceProcessor implements DocumentProcessor { ... }
class CreditNoteProcessor implements DocumentProcessor { ... }
class QuoteProcessor implements DocumentProcessor { ... }

Step 3: Modernize the Infrastructure

Upgrade PHP Versions

Each PHP major version brings performance improvements, new features, and security fixes. The upgrade path:

  1. Run static analysis at the target PHP version to find incompatibilities
  2. Fix incompatibilities with the current PHP version still running
  3. Update the runtime environment
  4. Run your test suite
  5. Deploy to staging and verify

Add Type Declarations

Gradually add parameter types, return types, and property types:

// Before
function calculateTotal($items) {
    $total = 0;
    foreach ($items as $item) {
        $total += $item['price'] * $item['quantity'];
    }
    return $total;
}

// After
function calculateTotal(array $items): float {
    $total = 0.0;
    foreach ($items as $item) {
        $total += (float) $item['price'] * (int) $item['quantity'];
    }
    return $total;
}

Type declarations catch bugs at runtime, improve IDE support, and make the code self-documenting.

Adopt Static Analysis

Start PHPStan or Psalm at the lowest level (level 0) and increase gradually. Each level catches more issues. Fix them as you go. Do not try to jump to the highest level immediately since the error count will be overwhelming.

Step 4: Migrate Component by Component

The Strangler Fig Pattern

For larger modernization efforts, build new implementations alongside the old:

  1. Implement the new version of a component
  2. Route a percentage of traffic to the new implementation
  3. Compare outputs between old and new
  4. Gradually increase traffic to the new implementation
  5. Remove the old implementation when confidence is high

This works for database access layers, authentication systems, payment processing, and any component with a clear interface.

Making Progress Visible

Legacy modernization is a long game. Keep stakeholders engaged by tracking and sharing:

  • Static analysis error count trending downward
  • Test coverage trending upward
  • Deployment frequency trending upward
  • Incident count in modernized areas trending downward

Visible progress justifies continued investment. Invisible progress eventually loses its budget.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call