Your Legacy PHP Codebase Isn't Hopeless
You ship a small bug fix. Suddenly, two other features break. Every deployment feels like gambling. The business depends on this app—it brings in revenue, customers use it daily—but nobody feels confident working on it.
You open a file that should be a simple list of functions and find a 2,000-line monolith of nested loops and if-statements. Comments like // Temporary fix from years before. Presentation, database queries, and business logic living together in what one developer described as a "glorious spaghetti mashup."
You ship a small bug fix. Suddenly, two other features break. Every deployment feels like gambling. The business depends on this app—it brings in revenue, customers use it daily—but nobody feels confident working on it.
Well, you're not alone. And your codebase isn't hopeless.
The Industry's Dirty Secret
Let's start with some numbers based on Composer Statistics, to see where we are standing as PHP community:
- ~13% of Composer installs are still running completely end-of-life PHP versions (7.x and 8.0)
- ~27% are on EOL or security-only versions (including PHP 8.1)
- Over 50% of the top 1,000 PHP packages still support PHP versions that no longer receive security updates
This isn't a "you" problem. This is an industry-wide condition. The codebase you inherited? Someone wrote it under deadline pressure with the tools and knowledge they had at the time. The mess you're maintaining? It grew organically over years, touched by dozens of hands, with employee turnover erasing institutional knowledge along the way.
The original developers weren't bad. They were trying to ship. Just like you.
The legacy software modernization market is projected to reach $27.3 billion by 2029
ResearchAndMarkets.com Report
The Trap: Rewrite or Suffer
When developers hit a certain threshold of pain, they start dreaming of rewrites. A fresh start. Freedom from technical debt. Modern frameworks from day one. Elegant solutions unburdened by historical compromises.
But the data on rewrites is sobering:
- According to the Standish Group CHAOS Report, projects developed from scratch have only a 23% success rate, which equals the failure rate. The remaining 54% are problematic (over budget, late, or reduced scope)
- A McKinsey/Oxford study of 5,400 IT projects found that 17% of large IT projects go so badly they threaten the company's very existence, with average overruns of 45% over budget and delivering 56% less value than predicted
- The same Standish data reveals small, incremental changes have only a 4% failure rate
Joel Spolsky famously called rewrites "the single worst strategic mistake that any software company can make." The crufty-looking parts of your codebase often embed hard-earned knowledge about corner cases and weird bugs. Every "temporary fix from 2014" likely solved a real problem that will resurface in your shiny rewrite.
So you're stuck: rewrite and face those odds, or suffer indefinitely?
There's a third option.
The Third Path: Incremental Transformation
What if you could modernize your codebase one method at a time, keeping the system running throughout? What if you could introduce enterprise patterns—CQRS, message-driven architecture, proper testing—without a feature freeze?
This is what we'll explore. And we'll use concrete examples with Ecotone, a PHP framework designed specifically for this incremental journey.
The approach is simple:
- Identify a painful area of your codebase
- Extract that logic into a message handler using a simple attribute
- Test it in isolation with zero infrastructure
- Switch it to async when needed—with a single line change
- Repeat
Let's see what this looks like in practice.
Example 1: The 800-Line Order Controller
Here's what legacy code typically looks like. Maybe this feels familiar:
class OrderController
{
public function placeOrder(Request $request)
{
// 50 lines of validation
$data = $request->all();
if (empty($data['customer_id'])) {
return response()->json(['error' => 'Customer required'], 400);
}
// ... more validation ...
// 100 lines of order creation
$order = new Order();
$order->customer_id = $data['customer_id'];
$order->status = 'pending';
$order->created_at = date('Y-m-d H:i:s');
// ... assign 15 more fields ...
DB::beginTransaction();
try {
$order->save();
// 80 lines of inventory check
foreach ($data['items'] as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new \Exception('Insufficient stock');
}
$product->stock -= $item['quantity'];
$product->save();
$orderItem = new OrderItem();
// ... more assignments ...
$orderItem->save();
}
// 60 lines of payment processing
$paymentGateway = new PaymentGateway(config('payment.key'));
$result = $paymentGateway->charge(
$data['payment_token'],
$order->total
);
if (!$result->success) {
throw new \Exception($result->error);
}
$order->payment_id = $result->transaction_id;
$order->status = 'paid';
$order->save();
// 40 lines of notification
$customer = Customer::find($data['customer_id']);
Mail::send('emails.order-confirmation', [
'order' => $order,
'customer' => $customer
], function ($message) use ($customer) {
$message->to($customer->email);
$message->subject('Order Confirmation');
});
// 30 lines of analytics
Analytics::track('order_placed', [
'order_id' => $order->id,
'total' => $order->total,
'items_count' => count($data['items'])
]);
// 20 lines of loyalty points
$customer->loyalty_points += floor($order->total / 10);
$customer->save();
DB::commit();
return response()->json(['order_id' => $order->id]);
} catch (\Exception $e) {
DB::rollback();
Log::error('Order failed: ' . $e->getMessage());
return response()->json(['error' => $e->getMessage()], 500);
}
}
}This controller does at least 7 different things:
- Validation
- Order creation
- Inventory management
- Payment processing
- Email notifications
- Analytics tracking
- Loyalty points calculation
Testing it requires a real database, a payment gateway, an email server, and analytics integration. One bug in loyalty points affects the entire order flow. Sending emails synchronously slows down the response. And good luck understanding this six months from now.
Step 1: Identify the First Extraction
Don't try to fix everything at once. Pick one piece that causes pain. Let's start with the notification—it's slow, and if it fails, should it really prevent the order from completing?
First, we create a simple event and handler:
// The event: a simple data object
class OrderWasPlaced
{
public function __construct(
public readonly string $orderId,
public readonly string $customerId,
public readonly float $total
) {}
}
// The handler: focused on one thing
class OrderNotificationHandler
{
public function __construct(
private Mailer $mailer,
private CustomRepository $customerRepository,
) {}
#[EventHandler]
public function sendConfirmation(OrderWasPlaced $event): void
{
$customer = $this->customerRepository->find($event->customerId);
$this->mailer->send('emails.order-confirmation', [
'orderId' => $event->orderId,
'customerName' => $customer->name,
'total' => $event->total
], $customer->email);
}
}That's it. One attribute: #[EventHandler]. Ecotone discovers it automatically. No configuration files. No service registration boilerplate.
We can extract Interfaces for Mailer and CustomerRepository which under the hood will call our Mailer::send or Customer::find which will make our testing a bit easier in next steps. But if your tooling has ability to mock those even with static methods, then we will be fine without interfaces here.
Step 2: Publish the Event from Your Existing Code
Now modify your existing controller minimally:
class OrderController
{
public function __construct(
private EventBus $eventBus // Injected by Ecotone
) {}
public function placeOrder(Request $request)
{
// ... all your existing code ...
// Publish the event (one line added)
$this->eventBus->publish(new OrderWasPlaced(
$order->id,
$customer->id,
$order->total
));
DB::commit();
return response()->json(['order_id' => $order->id]);
}
}Your legacy code still works exactly as before. But now the notification logic lives in its own class, with clear dependencies, doing one thing well.
Step 3: Test It in Isolation
Here's where the magic happens. That notification handler? You can test it right now, without Docker, without RabbitMQ, without a running application:
class OrderNotificationHandlerTest extends TestCase
{
public function test_sends_confirmation_email_when_order_placed(): void
{
// Arrange: set up test doubles
$customerRepository = $this->createMock(CustomerRepository::class);
$customerRepository->method('find')
->willReturn(new Customer(
id: 'cust-123',
name: 'John Doe',
email: 'john@example.com'
));
$mailer = $this->createMock(Mailer::class);
// Expect: the mailer should be called with correct params
$mailer->expects($this->once())
->method('send')
->with(
'emails.order-confirmation',
$this->callback(fn($data) =>
$data['orderId'] === 'order-456' &&
$data['customerName'] === 'John Doe'
),
'john@example.com'
);
// Act: run through Ecotone's test harness
$messaging = EcotoneLite::bootstrapFlowTesting(
[OrderNotificationHandler::class],
[
CustomerRepository::class => $customerRepository,
Mailer::class => $mailer
]
);
$messaging->publishEvent(new OrderWasPlaced(
orderId: 'order-456',
customerId: 'cust-123',
total: 99.99
));
// Assert: verification happens in the mock expectation
}
}Your tests run in milliseconds. No database. No message queue. No external services. Just pure business logic verification.
Step 4: Switch to Async (One Line)
Your notification works. It's tested. Now you want to make it asynchronous so it doesn't slow down the order response. Add one attribute:
class OrderNotificationHandler
{
#[Asynchronous('notifications')] // ← That's it
#[EventHandler]
public function sendConfirmation(OrderWasPlaced $event): void
{
// Exactly the same code
}
}Configure the channel once for your entire application:
class MessagingConfiguration
{
#[ServiceContext]
public function asyncChannels(): array
{
return [
// Start with database queue (no extra infrastructure)
DbalBackedMessageChannelBuilder::create('notifications'),
// Or use RabbitMQ when ready
// AmqpBackedMessageChannelBuilder::create('notifications'),
];
}
}Your tests still pass with one small change now. Ecotone's bootstrapFlowTesting handles both cases async and synchronous calls:
$messaging = EcotoneLite::bootstrapFlowTesting(
[OrderNotificationHandler::class],
[
CustomerRepository::class => $customerRepository,
Mailer::class => $mailer
],
enableAsynchronousProcessing: true,
);
$messaging->publishEvent(new OrderWasPlaced(
orderId: 'order-456',
customerId: 'cust-123',
total: 99.99
));
$messaging->run('notifications'); // this will trigger our Notification HandlerStep 5: Repeat for Each Concern
Now extract the next piece.
Analytics tracking:
class AnalyticsHandler
{
#[Asynchronous('analytics')]
#[EventHandler]
public function trackOrderPlaced(OrderWasPlaced $event): void
{
Analytics::track('order_placed', [
'order_id' => $event->orderId,
'total' => $event->total
]);
}
}Loyalty points:
class LoyaltyPointsHandler
{
#[EventHandler]
public function awardPoints(OrderWasPlaced $event): void
{
$points = (int) floor($event->total / 10);
$customer->loyalty_points += $points;
$customer->save();
}
}Each extraction makes your system more:
- Testable (isolated units with clear dependencies)
- Understandable (one class, one responsibility)
- Resilient (failures in analytics don't break orders)
- Flexible (easy to swap implementations)
Each Asynchronous Event Handler receives copy of OrderWasPlaced Event Message. This means, if it fails - it fails in full isolation, and retries will not affect any other Handler.
The Bigger Picture: Extracting Command Handlers
Events are great for side effects. But what about the core business logic? Let's tackle the order creation itself.
The approach is the same: move first, improve later. Don't rewrite — relocate.
Step A: Create the Command (Just a Data Bag)
class PlaceOrder
{
public function __construct(
public readonly string $customerId,
public readonly array $items,
public readonly string $paymentToken
) {}
}Step B: Move Your Existing Code Into a Handler
Here's the key insight: you don't need to refactor the code yet. Just move it:
class OrderHandler
{
public function __construct(private EventBus $eventBus) {}
#[CommandHandler]
public function placeOrder(PlaceOrder $command): string
{
// Check inventory (yes, still hitting DB directly - that's fine for now)
foreach ($command->items as $item) {
$product = DB::table('products')
->where('id', $item['product_id'])
->lockForUpdate()
->first();
if ($product->stock < $item['quantity']) {
throw new \Exception("Insufficient stock for {$product->name}");
}
DB::table('products')
->where('id', $item['product_id'])
->decrement('stock', $item['quantity']);
}
// Calculate total (same ugly loop as before)
$total = 0;
foreach ($command->items as $item) {
$product = DB::table('products')->find($item['product_id']);
$total += $product->price * $item['quantity'];
}
// Create order record
$orderId = DB::table('orders')->insertGetId([
'customer_id' => $command->customerId,
'total' => $total,
'status' => 'pending',
'created_at' => now()
]);
// Process payment (still using that old PaymentService)
$paymentService = app(PaymentService::class);
$result = $paymentService->charge($command->paymentToken, $total);
DB::table('orders')
->where('id', $orderId)
->update(['status' => 'paid', 'transaction_id' => $result['id']]);
// Publish event (this triggers all those handlers we extracted earlier)
$this->eventBus->publish(new OrderWasPlaced($orderId, $command->customerId, $total));
return $orderId;
}
}Yes, it's still messy. That's okay. You've achieved something important:
- The logic is now in an isolated class
- It publishes
OrderWasPlaced, triggering all those handlers we extracted earlier - It's testable with
EcotoneLite::bootstrapFlowTesting() - The controller no longer knows how orders work
(Note: The app(PaymentService::class) call isn't ideal — you'd eventually inject it through the constructor. But for now, the existing code works and that's what matters.)
class OrderController
{
public function __construct(private CommandBus $commandBus) {}
public function placeOrder(Request $request): JsonResponse
{
$orderId = $this->commandBus->send(new PlaceOrder(
customerId: $request->input('customer_id'),
items: $request->input('items'),
paymentToken: $request->input('payment_token')
));
return response()->json(['order_id' => $orderId]);
}
}That's it. Your controller is now 10 lines. The business logic lives in a command handler that you can test and improve independently.
You could wrap it in the Database transaction if required, but if you install Ecotone's dbal Module, all Command Handlers and inner Event Handlers will wrapped by transaction by default - therefore we don't need to do so.
Step C: Refactor When You're Ready (Not Before)
Now that the code is extracted, you can improve it incrementally:
- Extract an
InventoryServicewhen you need to reuse stock logic - Create an
OrderRepositorywhen you add a second handler that needs orders - Write tests that cover this functionality
But none of that is required to get the benefits of testability and separation today.
Step D: Testing the Complete Flow
Even with messy code, you can now test the entire flow synchronously:
class OrderFlowTest extends TestCase
{
public function test_complete_order_flow(): void
{
$messaging = EcotoneLite::bootstrapFlowTesting([
OrderHandler::class,
OrderNotificationHandler::class,
AnalyticsHandler::class,
LoyaltyPointsHandler::class,
], [
// Provide test doubles for whatever services your code uses
PaymentService::class => new FakePaymentService(),
Mailer::class => new FakeMailer(),
]);
// Execute the command
$orderId = $messaging->sendCommand(new PlaceOrder(
customerId: 'cust-123',
items: [['product_id' => 'prod-1', 'quantity' => 2]],
paymentToken: 'tok_visa'
));
// Verify events were published
$events = $messaging->getRecordedEvents();
$this->assertCount(1, $events);
$this->assertInstanceOf(OrderWasPlaced::class, $events[0]);
$this->assertEquals($orderId, $events[0]->orderId);
( do other assertions )
}
}At first we define list of classes taking part in this test suite. This is really powerful especially with a lot of dependencies and things happening in the flow. We limit only up to the point that actually want to test.
And in this scenario we test from the entrypoint which is Command, and we can assert any part of the logic that happens under the hood. Same event flow, same assertions—but in milliseconds, with no infrastructure - even for asynchronous processing.
Handling Legacy Database Code
Ecotone has a pattern for raw SQL queries scattered everywhere too:
// Before: SQL mixed with business logic
$orders = DB::select("
SELECT * FROM orders
WHERE customer_id = ?
AND status = 'pending'
ORDER BY created_at DESC
", [$customerId]);
// After: Declarative business interface
interface OrderQueries
{
#[DbalQuery(
"SELECT * FROM orders
WHERE customer_id = :customerId
AND status = 'pending'
ORDER BY created_at DESC"
)]
public function getPendingOrders(string $customerId): array;
#[DbalQuery(
"SELECT * FROM orders WHERE id = :orderId",
fetchMode: FetchMode::FIRST_ROW
)]
public function findById(string $orderId): ?array;
}Ecotone implements the interface automatically. You get type safety, clear contracts, and testable code—while keeping your existing database schema.
You could directly type hint with result object and Ecotone will do the mapping, so we don't need to deal with arrays. The same works for collections of returned objects using docblocks:
// After: Declarative business interface
interface OrderQueries
{
#[DbalQuery(
"SELECT * FROM orders
WHERE customer_id = :customerId
AND status = 'pending'
ORDER BY created_at DESC"
)]
/**
* @return PersonNameDTO[]
*/
public function getPendingOrders(string $customerId): array;
#[DbalQuery(
"SELECT * FROM orders WHERE id = :orderId",
fetchMode: FetchMode::FIRST_ROW
)]
public function findById(string $orderId): ?PersonNameDTO;
}Adding Resilience Without Rewriting
One of the biggest pain points with legacy code is error handling. What happens when the payment gateway times out? When the email server is down? When RabbitMQ loses connection?
With Ecotone, you add resilience declaratively:
// Automatic retries
#[ServiceContext]
public function retryConfiguration(): array
{
return [
InstantRetryConfiguration::createWithDefaults()
->withCommandBusRetry(
enabled: true,
maxRetryAttempts: 3,
retryOnlyForExceptions: [
PaymentGatewayTimeout::class,
DatabaseConnectionException::class
]
)
];
}For async handlers, failed messages go to a dead letter queue automatically:
#[ServiceContext]
public function errorHandling(): array
{
return [
ErrorHandlerConfiguration::createWithDeadLetterChannel(
'errorChannel',
RetryTemplateBuilder::exponentialBackoff(
initialDelayMs: 1000,
multiplier: 2
)->maxRetryAttempts(5),
// if retry strategy will not recover, then send here
"dbal_dead_letter"
)
];
}After 5 retries with exponential backoff, the message is stored in a dead letter table. You can review it, fix the bug, and replay it:
# See what failed
php artisan ecotone:deadletter:list
# Check the details
php artisan ecotone:deadletter:show abc-123
# Replay after fixing the bug
php artisan ecotone:deadletter:replay abc-123Your legacy code gets enterprise-grade resilience without a rewrite.
The Deduplication Problem
Ever had duplicate orders because a user clicked twice or same webhook event was received twice? Or duplicate emails because a queue message was processed twice? Add one attribute:
#[Deduplicated('orderId')]
#[CommandHandler]
public function placeOrder(PlaceOrder $command): string
{
// Automatically deduplicated based on orderId
// Second call with same orderId is silently ignored
}Idempotency baked in. No manual tracking. No distributed locks to implement.
Why This Works
The incremental approach succeeds because:
- The system keeps running. No feature freeze. No parallel development of two systems.
- Each step is small. A single handler extraction is a focused PR that reviewers can understand.
- Tests prove correctness. Before you change behavior, you capture it in tests. Refactoring becomes safe.
- Value compounds. Each extraction makes the next one easier. Patterns emerge. Developers learn.
- You can stop anytime. Even if you only extract 30% of your code, that 30% is now testable and maintainable.
- Modern patterns attract talent. CQRS, event-driven architecture, DDD—these are resume-worthy skills.
Getting Started Today
You don't need permission for a big initiative. You don't need a roadmap approved. You need one composer command and one extraction.
# Laravel
composer require ecotone/laravel
# Symfony
composer require ecotone/symfony-bundle
# Standalone
composer require ecotone/ecotoneThe standalone can be used with any other Framework, therefore can be used even with internal frameworks.
If you're working on legacy PHP that isn't supported by Ecotone's current version, you can install an older version and upgrade once you're ready. This works because Ecotone maintains a high-level declarative API that's decoupled from the framework internals. The API hasn't changed in over 5 years.
Conclusion: Your Codebase Has a Future
Those legacy systems, despite their flaws, have been successfully running businesses for years. They deserve respect for their longevity—and they deserve a path forward.
The patterns you've seen in this article aren't theoretical. They're the same patterns used at scale by companies processing millions of messages. And they're accessible to any PHP developer, starting today, in any existing codebase.
You don't have to rewrite everything. You don't have to suffer indefinitely. You can transform your application one handler at a time, testing each step, shipping continuously, and actually enjoying the process.
Remember those numbers from the beginning? Projects rewritten from scratch have only a 23% success rate. Incremental modernization, on the other hand, has a 53% success rate and only a 9% failure rate.
By choosing incremental transformation over a rewrite, you've already more than doubled your odds of success. You've the tools and knowledge, so now it's your time to punch that 2,000-line controller right in the face. The odds are on your side—go and make it happen!