Development

How to Refactor a Legacy PHP Application With No Tests

Refactoring a legacy PHP application with no tests starts with restraint. Before improving the design, learn which behaviors the business already depends on and build the smallest useful safety net around them.

The first goal is not clean architecture. It is a repeatable way to detect accidental change. Once that exists, you can create seams, isolate responsibilities, and move the code toward a better design in small steps.

Define the boundary of the first change

“Refactor the application” is too large to verify. Pick one business flow with a clear trigger and visible outcome, such as:

  • A customer submitting checkout
  • An administrator exporting a report
  • A scheduled job renewing access
  • An API endpoint updating a record
  • A login request establishing a session

Write down its inputs, outputs, persistent side effects, external calls, and failure behavior. Include the current quirks. A surprising redirect or unusual rounding rule might be a bug, but if callers rely on it, changing it belongs in a separate behavior-change decision.

Keep refactoring and behavior changes separate. A refactoring commit should preserve observable behavior. A bug fix should state which behavior changes and have a test that proves the new expectation.

Make the application reproducible

Before tests can be trusted, the environment must be repeatable. Record the PHP version, required extensions, database version, web-server assumptions, background jobs, writable directories, and environment variables.

Then create a sanitized database fixture that is small enough to reset quickly. It should include the important states for the chosen flow, not a full production copy. Remove personal data, credentials, API keys, payment tokens, and deliverability settings.

The minimum useful developer loop is:

  1. Start a known runtime.
  2. Create or restore a known database.
  3. Run the selected request or command.
  4. Assert the result and side effects.
  5. Reset to the known state.

If reset takes an hour, tests will be avoided. Invest in making it boring and quick.

Add smoke tests at the outside

Start at a stable system boundary. You do not need to instantiate every class or untangle every include before writing a useful test.

For an HTTP application, a smoke test can boot the app, request a critical route, and assert the status, a meaningful response fragment, and a database outcome. For a command-line job, run the command with fixture data and inspect its exit status and resulting records.

Do not assert the entire HTML document unless the exact markup is the contract. Full snapshots become noisy when harmless presentation details change. Assert business-relevant output instead.

The following skeleton uses PHPUnit 12.5, which requires PHP 8.3 or newer. That can be a separate test-tool runtime when it drives a legacy application over HTTP and inspects its test database from outside the application process. If the suite boots legacy source inside PHPUnit's PHP process, choose a PHPUnit release compatible with that application's runtime instead. The PHPUnit installation guide documents the current runner requirement.

ApplicationSmokeTestCase and InvoiceFixture below are intentionally application-specific harness components. The base test case must start or connect to the disposable application, expose an HTTP client through application(), and reset state in setup and teardown. The fixture factory must create two known accounts, invoices, and an authorized administrator without using production data.

<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\Test;

final class InvoiceExportSmokeTest extends ApplicationSmokeTestCase
{
    /**
     * Confirms that an authorized export returns the expected record set.
     */
    #[Test]
    public function itExportsOnlyInvoicesForTheSelectedAccount(): void
    {
        $fixture = InvoiceFixture::knownAccounts();
        $response = $this->application()->get(
            '/admin/export.php?account=' . $fixture->primaryAccountId(),
            ['HTTP_AUTHORIZATION' => $fixture->adminAuthorization()]
        );

        self::assertSame(200, $response->status());
        self::assertStringContainsString('invoice-alpha', $response->body());
        self::assertStringNotContainsString('invoice-other-account', $response->body());
    }
}

This is a harness contract, not a drop-in standalone test. Implement those named helpers in the repository before expecting the example to run. Keeping them in a project base class and fixture factory makes the test readable without pretending undefined helpers come from PHPUnit.

Characterize behavior before changing it

A characterization test records what the system does today. It is evidence, not an endorsement.

Cover a narrow but meaningful set:

  • One normal request
  • One invalid input
  • One unauthorized request
  • One external dependency failure
  • One boundary value, such as an empty result or expiration time
  • The database writes and messages the flow produces

If the current behavior is clearly dangerous, do not encode it silently as permanent truth. Name the test to show that it describes legacy behavior, document the risk, and create a separate approved change for the correction.

Find the first seam

A seam is a place where behavior can be changed or substituted without editing everything around it. In old PHP applications, useful seams often appear at:

  • Database connection creation
  • Time and random-value access
  • Mail, payment, HTTP, and filesystem calls
  • Global configuration reads
  • Static helpers with side effects
  • Front-controller includes

Suppose an invoice function creates its own PDO connection, reads $_POST, sends mail, and renders HTML. Do not rewrite all four concerns at once. Start by passing in the database connection. That one change makes transactions and fixtures easier to control.

<?php

declare(strict_types=1);

/**
 * Loads an invoice through a caller-owned database connection.
 *
 * @return array<string, mixed>|null
 */
function findInvoice(PDO $pdo, int $invoiceId): ?array
{
    $statement = $pdo->prepare(
        'SELECT id, account_id, status, total FROM invoices WHERE id = :id'
    );
    $statement->execute([':id' => $invoiceId]);
    $invoice = $statement->fetch(PDO::FETCH_ASSOC);

    return $invoice === false ? null : $invoice;
}

This is not the final architecture. It is a controlled seam. Later, the query can move behind a repository interface if that separation earns its cost.

Work in thin, reversible slices

A reliable sequence for each slice is:

  1. Capture the current behavior with a test.
  2. Make one structural change.
  3. Run the narrow test.
  4. Run the wider smoke suite.
  5. Review the diff for accidental behavior changes.
  6. Commit the coherent step.

Good early slices remove hidden dependencies. Pass configuration into a service, wrap the clock, centralize database creation, or move one calculation into a pure function. Pure business calculations are especially valuable because they can be tested without booting the full application.

Avoid extracting interfaces for every class merely to make the diagram look modern. Extract a boundary when there are multiple implementations, a volatile external dependency, or a testing need that cannot be met cleanly another way.

Use several layers of protection

No single test type catches everything. A practical legacy safety net combines:

  • Smoke tests for critical end-to-end flows
  • Characterization tests for fragile behavior
  • Integration tests for database queries and framework hooks
  • Unit tests for newly isolated business rules
  • Static analysis for impossible types and suspicious paths
  • Production monitoring for failures and outcome changes

Test doubles are useful at expensive or irreversible boundaries, such as charging a card or sending an email. They are less useful when they merely reproduce the internals of a database or framework. The PHPUnit test-double documentation is a good reference for the available tools and their intended roles.

Protect the deployment

Refactoring risk does not end when tests pass. Use a deployment strategy that limits impact:

  • Put the new path behind a feature flag when practical.
  • Log which path handled each request.
  • Compare business outcomes, not only error rates.
  • Keep database migrations backward compatible while old code may still run.
  • Define the rollback action before deployment.
  • Remove the old path only after the observation window.

For jobs with external side effects, add idempotency before running old and new implementations in parallel. Otherwise a comparison can send duplicate messages or create duplicate transactions.

A useful first milestone

The first milestone is complete when one important flow can be reproduced locally, reset from sanitized fixtures, exercised through a stable boundary, and protected by assertions that matter to the business.

At that point, you have more than a test. You have a repeatable learning system. Every later refactor can add another protected flow, deepen an existing seam, and reduce the part of the application that nobody can change confidently.

That is how a legacy PHP application becomes maintainable: not through a heroic rewrite, but through a sequence of small changes whose behavior you can prove.