Refactoring the "Legacy" Laravel App: Lessons from 6 Years in the Trenches

 🚧 Refactoring the "Legacy" Laravel App: Lessons from 6+ Years in the Trenches

Refactoring the "Legacy" Laravel App: Lessons from 6 Years in the Trenches


Every Laravel application 

✅Starts clean.

✅Controllers are small.

✅Models are elegant.

✅Business logic feels organized.

✅Clean Architecture.

Then reality happens.

New features arrive. Deadlines get tighter. Team members change. Temporary fixes become permanent architecture. That "simple" application slowly evolves into a system where a single controller method becomes more than 500+ lines, models contain more business logic than the service layer, and nobody wants to touch the checkout flow because nobody fully understands it.

After spending years maintaining and modernizing large Laravel applications, I've noticed a pattern:

Most legacy Laravel applications don't fail because of bad/unskilled developers. They fail because good code slowly becomes more complex over the time.

In this article we will see the architectural drift that naturally occurs in long-lived Laravel applications and, more importantly, how to refactor them safely without bringing production down.

🎯 The Real-World Bottleneck

Let's look at a simple example. A controller initially looking like this:

public function store(Request $request)
{
    $order = Order::create($request->validated());
    return response()->json($order);
}

Six years later, the same endpoint often looks like this:

public function store(Request $request)
{
    DB::beginTransaction();

    try {
        $customer = Customer::find($request->customer_id);

        if (!$customer) {
            throw new Exception('Customer not found');
        }

        $order = Order::create([
            'customer_id' => $customer->id,
            'total' => $request->total,
            'status' => 'pending'
        ]);

        foreach ($request->items as $item) {
            OrderItem::create([
                'order_id' => $order->id,
                'product_id' => $item['product_id'],
                'quantity' => $item['quantity']
            ]);
        }

        Mail::to($customer->email)->send(
            new OrderCreatedMail($order)
        );

        event(new OrderCreated($order));
        DB::commit();

        return response()->json($order);

    } catch (Exception $exception) {
        DB::rollBack();
        Log::error($exception);

        throw $exception;
    }
}

The problem isn't that this code doesn't work. The problem is that it has become responsible for:
  • Validation
  • Order creation
  • Inventory management
  • Event dispatching
  • Email notifications
  • Transaction management
  • Error handling
  • A single change now risks breaking multiple business processes.
  • This is architectural drift in action.

🔥 Why Traditional Refactoring Usually Fails

The worst approach is: "Let's rewrite everything." and This almost always ends badly.
Large rewrites introduce:
  • New bugs
  • Missing edge cases
  • Incomplete feature implementations
  • Long-lived branches
  • Production instability
In production systems, the goal isn't perfection. The goal is to improve the system gradually with minimal risk.

The best refactoring strategy is often to Enhance one responsibility at a time while preserving behavior.

Step 1: Identify Business Logic Hotspots

Before creating service classes everywhere, identify the areas causing the most pain.
Look for: Massive Controllers
  • OrderController
  • UserController
  • CheckoutController
  • PaymentController
Controllers exceeding 200-300 lines are usually hiding business logic.

Bloated Models

class Order extends Model
{
    public function calculateTax()
    {
    }

    public function reserveInventory()
    {
    }

    public function generateInvoice()
    {
    }

    public function sendNotification()
    {
    }

    public function processRefund()
    {
    }
}


Event Listeners Doing Too Much

I've seen listeners containing:
  • Database writes
  • API integrations
  • Queue dispatching
  • Notification logic
all inside a single handle() method.

Step 2: Extract Use Cases into Action Classes

One of the cleanest patterns for legacy Laravel applications is the Action pattern.

Instead of creating generic service classes like:

  • OrderService
  • CustomerService
  • UserService

focus on business operations.

Before

public function store(StoreOrderRequest $request)
{
    // 150 lines of order processing
}

After

public function store(
    StoreOrderRequest $request,
    CreateOrderAction $action
){
    $order = $action->execute(
        $request->validated()
    );

    return response()->json($order);
}

The controller becomes orchestration only.

Production-Grade Action Example

namespace App\Actions\Order;

use App\Models\Order;
use Illuminate\Support\Facades\DB;

class CreateOrderAction
{
    public function execute(array $data): Order
    {
        return DB::transaction(
            function () use ($data) {
                $order = Order::create([
                    'customer_id' => $data['customer_id'],
                    'total' => $data['total']
                ]);

                foreach ($data['items'] as $item) {
                    $order->items()->create([
                        'product_id' => $item['product_id'],
                        'quantity' => $item['quantity']
                    ]);
                }

                return $order;
            }
        );
    }
}

Now the business logic has a dedicated home. Controllers remain thin. Testing becomes significantly easier.

Step 3: Stop Treating Models as Service Containers

A common legacy anti-pattern looks like this:

$order->sendEmail();
$order->reserveInventory();
$order->generateInvoice();
$order->syncToERP();

The model becomes responsible for everything.

Instead:

  • GenerateInvoiceAction
  • ReserveInventoryAction
  • SyncOrderToErpAction

Each class should own one responsibility.

This follows the Single Responsibility Principle (One of the SOLID Principles) without overengineering.

Step 4: Refactor Without Breaking Production

When modernizing old systems, I prefer gradually replacing parts of the legacy application with new services/components rather than rebuilding everything at once.

Instead of replacing old code:

public function checkout()
{
    // old implementation
}

Introduce the new implementation gradually.

public function checkout(
    CheckoutAction $action
){
    return $action->execute();
}

Initially:

class CheckoutAction
{
    public function execute()
    {
        return app(LegacyCheckoutService::class)->process();
    }
}

Nothing changes.

Production remains stable.

Over time, move logic piece by piece from the legacy implementation into the action.

Eventually: LegacyCheckoutService can be removed entirely.

This dramatically reduces deployment risk.

⚡Handling the Edge Cases

Edge Case #1: Long Running Business Processes

A common problem appears when extracting logic from controllers.
Suppose order creation also triggers:
  • PDF generation
  • ERP synchronization
  • Email notifications
  • Analytics updates

The naive solution:

$action->execute();
$this->generatePdf();
$this->syncToERP();
$this->sendEmail();

Request times become slow.

Instead:

DB::afterCommit(function () use ($order) {
    GenerateInvoiceJob::dispatch($order);
    SyncOrderJob::dispatch($order);
    SendOrderEmailJob::dispatch($order);
});

In production, you'll want to move expensive operations to queues as early as possible.
Your users should not wait for background work.

Edge Case #2: Refactoring Shared Logic Used Everywhere

Imagine this method:

User::calculateCommission();

being used in:

  • API endpoints
  • Console commands
  • Scheduled jobs
  • Event listeners

Directly changing it is dangerous. Instead create a dedicated abstraction first as below:

class CalculateCommissionAction
{
    public function execute(User $user): float
    {
        // implementation
    }
}

Then migrate callers gradually and reduces blast radius significantly.

Edge Case #3: Transaction Boundaries

One mistake developers often make:

DB::transaction(function () {
    Order::create(...);
    Mail::send(...);
});

If the email provider fails, Everything rolls back.

A better approach:

$order = DB::transaction(function () {
    return Order::create(...);
});

SendOrderEmailJob::dispatch($order);

Database consistency remains isolated from external services.

📈 Measuring Refactoring Success

Many teams measure success incorrectly.
They focus on:
  • Number of files changed
  • Classes extracted
  • Lines of code removed
Better metrics include:

✅ Reduced controller complexity
✅ Easier unit testing
✅ Faster onboarding
✅ Smaller pull requests
✅ Lower deployment risk

The best refactoring is often invisible to users.

⚖️ The Engineering Trade-Offs

No architectural decision comes for free.

Pros:

  • Better Separation of Concerns ( Business logic has a predictable location. )
  • Easier Testing ( Actions can be tested independently. )
  • Reduced Controller Complexity ( Controllers become orchestration layers. )
  • Improved Team Productivity ( New developers find logic faster. )
  • Lower Production Risk (Incremental migrations avoid massive rewrites. )

Cons:

A feature may now involve new more files like:
  • Controller
  • Request
  • Action
  • Job
  • Event
  • Listener
instead of one controller method. Now we have
  • Additional Abstractions
  • Poorly designed action classes can become another layer of indirection.
  • Migration Takes Time
  • Legacy systems rarely improve overnight.
  • Expect months, not days.
  • Temporary Duplication
  • During migration, old and new implementations often coexist.

And YES This is normal.

🏁 Final Thoughts

The biggest lesson from maintaining Laravel applications for years is this:

Legacy code is rarely the result of bad decisions. It's usually the result of successful software surviving long enough to accumulate complexity.

Don't declare war on the codebase.

Don't schedule a six-month rewrite.

Don't chase architectural purity.

Instead:

  • Extract one use case at a time
  • Move business logic into dedicated Actions
  • Keep controllers focused on orchestration
  • Push heavy work into queues
  • Refactor incrementally behind stable interfaces

A clean architecture isn't something you build once. It's something you continuously defend.

And in long-lived Laravel applications, the team that wins are not the ones who rewrite everything.

They're the ones who improve the system one safe deployment at a time. 🚀


Thank you for reading this article 😊

For any query do not hesitate to comment 💬



Previous Post Next Post

Contact Form