Managing Legacy PHP: From Technical Debt to Modern Architecture
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