A black-and-white photograph of a steel reinforcement plate bolted onto the weathered wooden bow of a historic schooner in dry dock, with a second vessel and the dry dock wooden staging structure visible behind it
PHP

Managing Legacy PHP: From Technical Debt to Modern Architecture

•14 min read•By Joseph Edmonds•Image: National Park Service (HAER), public domain, via Wikimedia Commons

Practical strategies for transforming legacy PHP codebases into maintainable, modern systems without breaking production.

Legacy PHP systems are everywhere. They're the backbone of countless businesses, running critical operations that can't afford downtime. But they're also riddled with technical debt, outdated patterns, and maintenance nightmares that slow down development and increase costs.

Years of wrestling with legacy PHP codebases have taught me that modernisation works best as a series of strategic, incremental improvements rather than a full rewrite, each one delivering immediate value whilst building toward a sustainable future.

The Reality of Legacy PHP

Most legacy PHP systems share common characteristics:

  • Mixed responsibilities: Database queries embedded in templates, business logic scattered throughout presentation layers
  • Global state pollution: Heavy reliance on global variables, superglobals, and shared mutable state
  • Inconsistent coding standards: Multiple developers over many years, each with different approaches
  • Outdated dependencies: Old PHP versions, unmaintained libraries, security vulnerabilities
  • No automated testing: Manual testing processes that slow down changes and increase risk

The temptation is always to start fresh, but that's rarely the right answer. These systems work, they generate revenue, and they embody years of business logic that would be expensive to rebuild.

The Modernisation Strategy

1. Establish a Safety Net

Before making any changes, you need confidence that you won't break production. This means:

  • Comprehensive monitoring: Error logging, performance monitoring, user behaviour tracking
  • Automated backups: Both database and file system, with tested restore procedures
  • Staging environments: Production-like environments for testing changes
  • Feature flags: Ability to roll back changes without deploying new code

2. Identify High-Value Targets

Not all legacy code is created equal. Focus on areas that will give you the biggest impact:

  • Performance bottlenecks: Slow queries, inefficient algorithms, resource-intensive operations
  • Security vulnerabilities: SQL injection, XSS vulnerabilities, authentication issues
  • Frequently changed code: Areas where developers spend the most time
  • Business-critical functions: Core revenue-generating features

3. Implement the Strangler Fig Pattern

This pattern allows you to gradually replace old code with new code by routing requests through a facade:

<?php

declare(strict_types=1);

namespace App\Services\User;

use App\Contracts\UserServiceInterface;
use App\Entities\User;
use App\ValueObjects\UserId;

final readonly class StranglerFigUserService implements UserServiceInterface
{
    public function __construct(
        private UserServiceInterface $legacyService,
        private UserServiceInterface $modernService,
        private FeatureToggleService $featureToggle,
    ) {
    }

    public function getUser(UserId $id): User
    {
        return match ($this->featureToggle->isEnabled('modern_user_service', $id)) {
            true  => $this->modernService->getUser($id),
            false => $this->legacyService->getUser($id),
        };
    }

    private function shouldUseModernImplementation(UserId $id): bool
    {
        // Canary release: 10% of users
        return $id->value % 10 === 0;
    }
}

Practical Modernisation Techniques

Dependency Injection

Replace global state with explicit dependencies:

<?php

declare(strict_types=1);

// Before: Global database connection
function getUser(int $id): array|false
{
    global $db;

    return $db->query("SELECT * FROM users WHERE id = {$id}")->fetch();
}

// After: Modern dependency injection with proper typing
final readonly class UserRepository implements UserRepositoryInterface
{
    public function __construct(
        private PDO $connection,
        private UserHydrator $hydrator,
    ) {
    }

    public function findById(UserId $id): ?User
    {
        $stmt = $this->connection->prepare(<<< 'SQL'
            SELECT id, email, name, created_at, updated_at
            FROM users 
            WHERE id = :id AND deleted_at IS NULL
            SQL);

        $stmt->execute(['id' => $id->value]);
        $userData = $stmt->fetch();

        return $userData ? $this->hydrator->hydrate($userData) : null;
    }
}

Extract Service Classes

Move business logic out of controllers and into dedicated service classes:

<?php

declare(strict_types=1);

namespace App\Services\Order;

use App\Entities\Order;
use App\Events\OrderPlaced;
use App\Exceptions\{PaymentFailedException};
use App\ValueObjects\{OrderId};

final readonly class OrderService
{
    public function __construct(
        private OrderValidator $validator,
        private PriceCalculator $calculator,
        private PaymentGateway $paymentGateway,
        private OrderRepository $repository,
        private EventDispatcher $eventDispatcher,
    ) {
    }

    public function processOrder(OrderData $orderData): Order
    {
        $this->validator->validate($orderData);

        $order = Order::create(
            OrderId::generate(),
            $orderData->customerId,
            $orderData->items,
            $this->calculator->calculate($orderData->items)
        );

        $paymentResult = $this->paymentGateway->charge(
            $order->total,
            $orderData->paymentMethod
        );

        if (!$paymentResult->isSuccessful()) {
            throw new PaymentFailedException($paymentResult->errorMessage);
        }

        $order->markAsPaid($paymentResult->transactionId);
        $this->repository->save($order);

        $this->eventDispatcher->dispatch(
            new OrderPlaced($order->id, $order->customerId)
        );

        return $order;
    }
}

Implement Automated Testing

Start with integration tests for critical paths, then add unit tests as you refactor:

<?php

declare(strict_types=1);

namespace Tests\Unit\Services\Order;

use App\Testing\{OrderDataBuilder, PaymentResultBuilder};
use PHPUnit\Framework\Attributes\{Test, TestDox};
use PHPUnit\Framework\TestCase;

final class OrderServiceTest extends TestCase
{
    // setUp() omitted for brevity: assume $paymentGateway, $repository, $eventDispatcher
    // and $orderService are Mockery mocks wired up via the MockeryPHPUnitIntegration trait.

    #[Test]
    #[TestDox('Successfully processes valid order with payment')]
    public function processOrder_WithValidData_CreatesOrderAndProcessesPayment(): void
    {
        // Arrange
        $orderData = OrderDataBuilder::new()
            ->withCustomer(CustomerId::fromString('cust_123'))
            ->withItems([
                OrderItemBuilder::new()->withProduct('prod_456')->build(),
            ])
            ->build();

        $paymentResult = PaymentResultBuilder::successful()
            ->withTransactionId('txn_789')
            ->build();

        $this->paymentGateway->shouldReceive('charge')
            ->once()
            ->with(Money::fromCents(1000), $orderData->paymentMethod)
            ->andReturn($paymentResult);

        // Act
        $order = $this->orderService->processOrder($orderData);

        // Assert
        $this->assertInstanceOf(Order::class, $order);
        $this->assertTrue($order->isPaid());
        $this->assertEquals('txn_789', $order->transactionId->value);

        $this->repository->shouldHaveReceived('save')
            ->once()
            ->with($order);

        $this->eventDispatcher->shouldHaveReceived('dispatch')
            ->once()
            ->with(\Mockery::type(OrderPlaced::class));
    }
}

Managing the Transition

Team Buy-in

Modernisation efforts fail without team support. Make sure everyone understands:

  • The business case for modernisation
  • How changes will improve their daily work
  • The incremental approach that minimises risk
  • Success metrics and how progress will be measured

Documentation and Knowledge Transfer

Legacy systems often have tribal knowledge. Document:

  • Business rules embedded in code
  • Integration points and data flows
  • Deployment procedures, environment setup, and common troubleshooting scenarios

Common Pitfalls to Avoid

  • Big bang rewrites: They rarely work and often fail spectacularly
  • Perfectionism: Don't let perfect be the enemy of good
  • Ignoring performance: Modern doesn't always mean faster
  • Over-engineering: Solve today's problems, not imaginary future ones
  • Neglecting deployment: Modernise your deployment process alongside your code

Measuring Success

Track metrics that matter to both developers and business stakeholders:

  • Code quality: Test coverage, code complexity, technical debt ratio
  • Performance: Page load times, database query performance, memory usage
  • Developer productivity: Time to implement features, deployment frequency
  • Business impact: Bug reports, customer satisfaction, revenue impact

Playing the Long Game

Legacy PHP modernisation is a marathon, not a sprint. The goal is a system that serves the business reliably, stays maintainable, and can evolve with changing requirements, rather than chasing the newest technology stack for its own sake.

Every legacy system got that way by being successful. Respect that success whilst building for the future.

Got a PHP system that needs this kind of attention?

Get in touch