🚧 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.
public function store(Request $request)
{
$order = Order::create($request->validated());
return response()->json($order);
}
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;
}
}
- 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.
- New bugs
- Missing edge cases
- Incomplete feature implementations
- Long-lived branches
- Production instability
Step 1: Identify Business Logic Hotspots
- OrderController
- UserController
- CheckoutController
- PaymentController
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
- Database writes
- API integrations
- Queue dispatching
- Notification logic
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
- PDF generation
- ERP synchronization
- Email notifications
- Analytics updates
The naive solution:
$action->execute();
$this->generatePdf();
$this->syncToERP();
$this->sendEmail();
Instead:
DB::afterCommit(function () use ($order) {
GenerateInvoiceJob::dispatch($order);
SyncOrderJob::dispatch($order);
SendOrderEmailJob::dispatch($order);
});
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
- Number of files changed
- Classes extracted
- Lines of code removed
⚖️ 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:
- Controller
- Request
- Action
- Job
- Event
- Listener
- 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.
🏁 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. 🚀
