Software Archaeology - Digging Through Legacy PHP Code

Software Archaeology - Digging Through Legacy PHP Code

  1. PHP 🐘
  2. 2023-10-20 21:00
  3. 10 min read

Every developer eventually faces a legacy codebase. It might be a ten-year-old PHP application that runs a critical business process. It might be a project handed over by a team that left no documentation. It might even be code you wrote six months ago that now looks foreign.

Software archaeology is the practice of digging into existing code to understand how it works and why it was written that way. It is a skill as important as writing new code, yet rarely taught. This article covers tools and techniques for navigating unfamiliar PHP codebases, from git history analysis to step debugging with Xdebug to writing safety-net tests.

What You’ll Learn

  • Using git history to find authors and understand code evolution
  • Text search strategies for locating relevant code in large projects
  • Debugging techniques with var_dump, Symfony var-dumper, and Xdebug
  • Setting up Xdebug step debugging in Visual Studio Code and PHPStorm
  • Writing unit, functional, and behavioral tests for legacy code
  • Patterns for safely modifying code you do not fully understand

Finding Your Bearings with Git

When you join a project with no documentation, the git history is your primary source of truth. It tells you who wrote each line, when, and why.

Finding Contributors

git shortlog -sne

This command lists every contributor sorted by commit count. The -s flag shows only the commit count and name. The -n flag sorts by number of commits. The -e flag includes email addresses.

The contributors with the most commits are likely the ones who know the code best. If they still work at the company, reach out to them. If they have left, the commit messages they left behind become even more valuable.

Blame and Log

git blame src/Controllers/OrderController.php
git log --all --full-history -- src/Controllers/OrderController.php

git blame shows who last modified each line and when. git log shows the full history of changes to a file. Cross-referencing these tells you which commits introduced specific features or bugs.

Searching by Commit Message

git log --all --grep="payment" --oneline

Searching commit messages often reveals why a particular approach was taken. Feature additions, bug fixes, and workarounds are typically described in commit messages.

Searching Through Files

When you do not know where to start, search is your best tool. Modern IDEs provide powerful search capabilities, but command-line tools are often faster.

egrep -nHir "AbstractField" src/

This searches recursively through src/ for occurrences of AbstractField, returning filenames and line numbers. The -i flag makes the search case-insensitive. Drop it when you know the exact capitalization to narrow results.

Start with broad searches and narrow down. Search for a URL route to find the controller. Search for a database table name to find the model. Search for a CSS class visible on the page to find the template.

Finding Routes

Routes are excellent entry points. In Laravel, routes are typically in routes/web.php or routes/api.php. In Symfony, they are in YAML or PHP files under config/routes/. In older applications, routing might be embedded in .htaccess files or a front controller.

// routes/web.php
Route::get('/orders/{id}', [OrderController::class, 'show']);
Route::post('/orders', [OrderController::class, 'store']);

Once you find the route, you know which controller and method handle the request. From there, you trace through the method to understand the flow.

Debugging Without a Debugger

The simplest debugging technique is also the most accessible: var_dump() and die().

$someVariable = "Hello, debugging!";
var_dump($someVariable);
die();

Execution halts, and the variable’s contents are printed. This technique works everywhere, requires no setup, and is immediately understandable.

For tracking control flow:

if ($condition) {
    die('Reached inside the condition.');
} else if ($someOtherCondition) {
    die('We are here now');
}

This tells you which branch the code follows for a given input. Combine with var_dump() to see both the path and the data.

The disadvantage is that var_dump() output disrupts APIs and AJAX responses. If you are debugging an API endpoint, the dumped data breaks the JSON or XML the client expects.

Symfony Var-Dumper for Cleaner Output

The symfony/var-dumper package provides dump() and dd() functions that produce formatted, collapsible output without disrupting the response.

composer require symfony/var-dumper
require_once '/path/to/vendor/autoload.php';
 
$complexData = ['user' => ['name' => 'John', 'roles' => ['admin']]];
dump($complexData);
 
// dump and die
dd($complexData);

The dd() function combines dump and die in one call. The output is styled with expandable sections for arrays and objects. For CLI debugging, the output is plain text formatted for readability.

The package also captures output to avoid polluting HTTP responses, making it suitable for API debugging.

Step Debugging with Xdebug

var_dump() and dd() are useful for quick checks, but they require adding and removing code. Xdebug provides step debugging — pausing execution at specific lines and inspecting the complete program state.

Installing Xdebug

pecl install xdebug

Or through your package manager:

apt install php-xdebug
brew install php-xdebug

Verify the installation:

php -v
# Should show: with Xdebug v3.x

Configuring Xdebug

Create xdebug.ini:

zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_port=9003

The mode=debug setting enables step debugging. start_with_request=yes tells Xdebug to attach to every request. client_port=9003 is the default port for VS Code and PHPStorm.

Setting Breakpoints

In your IDE, click the gutter next to the line number where you want execution to pause. A red dot appears — this is your breakpoint.

When you make a request to your application, PHP pauses at the breakpoint and waits for the IDE to take control. You can then:

  • Step Over — execute the next line and pause again
  • Step Into — enter a function or method call
  • Step Out — complete the current function and return to the caller
  • Continue — run until the next breakpoint

Inspecting Variables

When paused, the IDE shows all variables currently in scope. You see their values, types, and structure. This is far more informative than var_dump() because you see the complete state — not just the variables you thought to dump.

For legacy code, set breakpoints at the beginning of a method and step through line by line. You will discover what each variable contains, which branches are taken, and how data flows through the system.

Writing Tests for Legacy Code

Before modifying legacy code, write tests that document its current behavior. These tests serve as a safety net — they tell you if your changes break existing functionality.

Unit Tests

Unit tests verify individual methods in isolation. They are the easiest to write but the hardest to retrofit into legacy code because dependencies are often tightly coupled.

public function testGetParts(): void
{
    $e = CronExpression::factory('0 22 * * 1-5');
    $parts = $e->getParts();
 
    $this->assertSame('0', $parts[0]);
    $this->assertSame('22', $parts[1]);
    $this->assertSame('*', $parts[2]);
    $this->assertSame('*', $parts[3]);
    $this->assertSame('1-5', $parts[4]);
}

This test verifies that parsing a cron expression produces the expected parts. It is a focused, isolated test that documents how the parsing method works.

Functional Tests

Functional tests verify that multiple systems work together. They make real HTTP requests to your application and check the responses.

public function testCreateOrder(): void
{
    $response = $this->post('/orders', [
        'product_id' => 1,
        'quantity' => 2,
    ]);
 
    $response->assertStatus(201);
    $this->assertDatabaseHas('orders', [
        'product_id' => 1,
        'quantity' => 2,
    ]);
}

For legacy applications, functional tests provide the most value with the least setup. They do not require refactoring the code to be testable — they test it as-is by simulating real user interactions.

Behavioral Tests

Behavioral tests describe system behavior in plain language. Behat and Codeception support this style:

Feature: Order Creation
  Scenario: Customer creates an order
    Given I am logged in as a customer
    When I add product "1" to my cart with quantity "2"
    Then I should see an order confirmation
    And the order should be in the database

Behavioral tests serve as living documentation. Non-technical stakeholders can read them to understand what the system does.

Real-World Use Cases

Migration Projects. Moving a legacy PHP application from one framework to another requires deep understanding of the existing code. Software archaeology techniques reveal the hidden assumptions and edge cases that documentation never captured.

Bug Fixes in Unfamiliar Code. A bug report arrives for a system nobody on the team built. Git history identifies the original author. Xdebug traces the execution path. Tests document the expected behavior before any changes are made.

Security Audits. Finding security vulnerabilities in old code requires understanding how data flows through the system. Search for input handling, SQL queries, and output rendering. Xdebug helps trace request data from entry point to database and back.

Performance Optimization. Slow legacy code often has obvious bottlenecks once you find them. Xdebug’s profiling mode shows which functions consume the most time. Functional tests provide a baseline before optimization begins.

Best Practices

Always write a test before changing legacy code. Even a simple smoke test that the page loads without errors provides value. You can verify that your change does not break the basic functionality.

Use git history generously. The commit log is a diary of the project’s evolution. Read commit messages to understand why code was written a certain way. Use blame to find the author of confusing lines.

Start at the edges. Begin by understanding the input and output of a system. Trace backwards from the visible result to find the source. Routes are excellent starting points for web applications.

Document as you learn. Leave comments for your future self. Update README files. Write tests that document behavior. The knowledge you gain today will be forgotten tomorrow if not captured.

Common Mistakes to Avoid

Refactoring before understanding. Do not reorganize code until you know what it does and how it is used. A rename or restructuring can break hidden dependencies.

Only unit testing. Legacy code is often untestable at the unit level due to tight coupling. Start with functional or behavioral tests that work at a higher level of abstraction.

Trusting comments. Comments can be misleading. They describe what the author thought the code did, which may not match reality. Trust the code, not the comments.

Deleting dead code without verification. What looks unused might be invoked through reflection, dynamic method calls, or configuration files. Verify thoroughly before removing.

Frequently Asked Questions

How do I know if legacy code is worth keeping? If the code serves a business purpose that is still relevant, it is worth maintaining. If the business process has changed and the code is no longer executed, remove it.

Should I rewrite legacy code or refactor it? Refactor incrementally. Rewrites are risky because the existing code embodies years of bug fixes and edge-case handling that will be missed in a rewrite.

How do I convince management to invest in legacy code improvement? Frame it in business terms: technical debt slows feature delivery. Show how much time is spent working around legacy issues. Propose incremental improvements rather than a full rewrite.

What if there are no tests and I cannot add any? Add tests anyway. Even a single functional test for the most critical code path provides a foundation. Build from there.

How do I deal with code that has no error handling? Add error handling as you touch each method. Do not attempt to fix everything at once. Follow the “leave it better than you found it” principle.

Conclusion

Legacy code is not a punishment. It is the accumulated knowledge of everyone who worked on the project before you. Software archaeology gives you the tools to extract that knowledge.

Git history tells you who and why. Text search tells you where. var_dump() and Xdebug tell you what. Tests tell you what should stay the same. Combined, these tools transform an intimidating codebase into a landscape you can navigate with confidence.

The next time you open a PHP project you have never seen before, do not despair. You are an archaeologist now. Grab your tools and start digging.

PHP Legacy Code Debugging Xdebug Refactoring Testing Git Software Archaeology